diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 12:04:41 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 12:04:41 +0000 |
commit | 975f66f2eebe9dadba04f275774d4ab83f74cf25 (patch) | |
tree | 89bd26a93aaae6a25749145b7e4bca4a1e75b2be /ansible_collections/community/dns/tests/unit | |
parent | Initial commit. (diff) | |
download | ansible-975f66f2eebe9dadba04f275774d4ab83f74cf25.tar.xz ansible-975f66f2eebe9dadba04f275774d4ab83f74cf25.zip |
Adding upstream version 7.7.0+dfsg.upstream/7.7.0+dfsg
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'ansible_collections/community/dns/tests/unit')
36 files changed, 17955 insertions, 0 deletions
diff --git a/ansible_collections/community/dns/tests/unit/plugins/inventory/test_hetzner_dns_records.py b/ansible_collections/community/dns/tests/unit/plugins/inventory/test_hetzner_dns_records.py new file mode 100644 index 000000000..3b01de586 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/inventory/test_hetzner_dns_records.py @@ -0,0 +1,529 @@ +# Copyright (c), Felix Fontein <felix@fontein.de>, 2021 +# 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 os +import textwrap + +from ansible import constants as C +from ansible.inventory.manager import InventoryManager +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.internal_test_tools.tests.unit.mock.path import mock_unfrackpath_noop +from ansible_collections.community.internal_test_tools.tests.unit.mock.loader import DictDataLoader +from ansible_collections.community.internal_test_tools.tests.unit.utils.open_url_framework import ( + OpenUrlCall, + OpenUrlProxy, +) + + +HETZNER_DEFAULT_ZONE = { + 'id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + 'legacy_dns_host': 'string', + 'legacy_ns': ['string'], + 'name': 'example.com', + 'ns': ['string'], + 'owner': 'Example', + 'paused': True, + 'permission': 'string', + 'project': 'string', + 'registrar': 'string', + 'status': 'verified', + 'ttl': 10800, + 'verified': '2021-07-09T11:18:37Z', + 'records_count': 0, + 'is_secondary_dns': True, + 'txt_verification': { + 'name': 'string', + 'token': 'string', + }, +} + +HETZNER_JSON_DEFAULT_ENTRIES = [ + { + 'id': '125', + 'type': 'A', + 'name': '@', + 'value': '1.2.3.4', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '126', + 'type': 'A', + 'name': '*', + 'value': '1.2.3.5', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '127', + 'type': 'AAAA', + 'name': '@', + 'value': '2001:1:2::3', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '128', + 'type': 'AAAA', + 'name': 'foo', + 'value': '2001:1:2::4', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '129', + 'type': 'MX', + 'name': '@', + 'value': '10 example.com', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '130', + 'type': 'CNAME', + 'name': 'bar', + 'value': 'example.org.', + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, +] + +HETZNER_JSON_BAD_ENTRIES = [ + { + 'id': '125', + 'type': 'TXT', + 'name': '@', + 'value': '"this is wrongly quoted', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, +] + +HETZNER_JSON_ZONE_LIST_RESULT = { + 'zones': [ + HETZNER_DEFAULT_ZONE, + ], +} + +HETZNER_JSON_ZONE_GET_RESULT = { + 'zone': HETZNER_DEFAULT_ZONE, +} + +HETZNER_JSON_ZONE_RECORDS_GET_RESULT = { + 'records': HETZNER_JSON_DEFAULT_ENTRIES, +} + +HETZNER_JSON_ZONE_RECORDS_GET_RESULT_2 = { + 'records': HETZNER_JSON_BAD_ENTRIES, +} + + +original_exists = os.path.exists +original_access = os.access + + +def exists_mock(path, exists=True): + def exists(f): + if to_native(f) == path: + return exists + return original_exists(f) + + return exists + + +def access_mock(path, can_access=True): + def access(f, m, *args, **kwargs): + if to_native(f) == path: + return can_access + return original_access(f, m, *args, **kwargs) + + return access + + +def test_inventory_file_simple(mocker): + inventory_filename = "test.hetzner_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: foo + zone_name: example.com + filters: + type: A + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert im._inventory.hosts + assert 'example.com' in im._inventory.hosts + assert '*.example.com' in im._inventory.hosts + assert 'foo.example.com' not in im._inventory.hosts + assert 'bar.example.com' not in im._inventory.hosts + assert im._inventory.get_host('example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('*.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('example.com').get_vars()['ansible_host'] == '1.2.3.4' + assert im._inventory.get_host('*.example.com').get_vars()['ansible_host'] == '1.2.3.5' + assert len(im._inventory.groups['ungrouped'].hosts) == 2 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_collision(mocker): + inventory_filename = "test.hetzner_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: '{{ "foo" }}' + zone_name: '{{ "example." ~ "com" }}' + filters: + type: + - A + - AAAA + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert im._inventory.hosts + assert 'example.com' in im._inventory.hosts + assert '*.example.com' in im._inventory.hosts + assert 'foo.example.com' in im._inventory.hosts + assert 'bar.example.com' not in im._inventory.hosts + assert im._inventory.get_host('example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('*.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('foo.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('example.com').get_vars()['ansible_host'] == '2001:1:2::3' + assert im._inventory.get_host('*.example.com').get_vars()['ansible_host'] == '1.2.3.5' + assert im._inventory.get_host('foo.example.com').get_vars()['ansible_host'] == '2001:1:2::4' + assert len(im._inventory.groups['ungrouped'].hosts) == 3 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_no_filter(mocker): + inventory_filename = "test.hetzner_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: foo + zone_id: '42' + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert im._inventory.hosts + assert 'example.com' in im._inventory.hosts + assert '*.example.com' in im._inventory.hosts + assert 'foo.example.com' in im._inventory.hosts + assert 'bar.example.com' in im._inventory.hosts + assert im._inventory.get_host('example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('*.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('foo.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('bar.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('example.com').get_vars()['ansible_host'] == '2001:1:2::3' + assert im._inventory.get_host('*.example.com').get_vars()['ansible_host'] == '1.2.3.5' + assert im._inventory.get_host('foo.example.com').get_vars()['ansible_host'] == '2001:1:2::4' + assert im._inventory.get_host('bar.example.com').get_vars()['ansible_host'] == 'example.org.' + assert len(im._inventory.groups['ungrouped'].hosts) == 4 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_record_conversion_error(mocker): + inventory_filename = "test.hetzner_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: foo + zone_id: "{{ '42' }}" + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT_2), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + # TODO: make sure that the correct error was reported + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_missing_zone(mocker): + inventory_filename = "test.hetzner_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: foo + """)} + + open_url = OpenUrlProxy([ + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + # TODO: make sure that the correct error was reported + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_zone_not_found(mocker): + inventory_filename = "test.hetzner_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: foo + zone_id: '23' + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + # TODO: make sure that the correct error was reported + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_unauthorized(mocker): + inventory_filename = "test.hetzner_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: foo + zone_id: '23' + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .result_json(dict(message="")), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + # TODO: make sure that the correct error was reported + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_error(mocker): + inventory_filename = "test.hetzner_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: foo + zone_id: '42' + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .result_json({}), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + # TODO: make sure that the correct error was reported + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_wrong_file(mocker): + inventory_filename = "test.hetznerdns.yml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: foo + """)} + + open_url = OpenUrlProxy([]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + # TODO: make sure that the correct error was reported + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_no_file(mocker): + inventory_filename = "test.hetzner_dns.yml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + + open_url = OpenUrlProxy([]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename, False)) + mocker.patch('os.access', access_mock(inventory_filename, False)) + im = InventoryManager(loader=DictDataLoader({}), sources=inventory_filename) + + open_url.assert_is_done() + + # TODO: make sure that the correct error was reported + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 diff --git a/ansible_collections/community/dns/tests/unit/plugins/inventory/test_hosttech_dns_records.py b/ansible_collections/community/dns/tests/unit/plugins/inventory/test_hosttech_dns_records.py new file mode 100644 index 000000000..a7adb2c09 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/inventory/test_hosttech_dns_records.py @@ -0,0 +1,465 @@ +# Copyright (c), Felix Fontein <felix@fontein.de>, 2021 +# 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 os +import textwrap + +from ansible import constants as C +from ansible.inventory.manager import InventoryManager +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.internal_test_tools.tests.unit.mock.path import mock_unfrackpath_noop +from ansible_collections.community.internal_test_tools.tests.unit.mock.loader import DictDataLoader +from ansible_collections.community.internal_test_tools.tests.unit.utils.open_url_framework import ( + OpenUrlCall, + OpenUrlProxy, +) + + +HOSTTECH_WSDL_DEFAULT_ENTRIES = [ + (125, 42, 'A', '', '1.2.3.4', 3600, None, None), + (126, 42, 'A', '*', '1.2.3.5', 3600, None, None), + (127, 42, 'AAAA', '', '2001:1:2::3', 3600, None, None), + (128, 42, 'AAAA', 'foo', '2001:1:2::4', 3600, None, None), + (129, 42, 'MX', '', 'example.com', 3600, None, '10'), + (130, 42, 'CNAME', 'bar', 'example.org.', 10800, None, None), +] + +HOSTTECH_JSON_DEFAULT_ENTRIES = [ + # (125, 42, 'A', '', '1.2.3.4', 3600, None, None), + { + 'id': 125, + 'type': 'A', + 'name': '', + 'ipv4': '1.2.3.4', + 'ttl': 3600, + 'comment': '', + }, + # (126, 42, 'A', '*', '1.2.3.5', 3600, None, None), + { + 'id': 126, + 'type': 'A', + 'name': '*', + 'ipv4': '1.2.3.5', + 'ttl': 3600, + 'comment': '', + }, + # (127, 42, 'AAAA', '', '2001:1:2::3', 3600, None, None), + { + 'id': 127, + 'type': 'AAAA', + 'name': '', + 'ipv6': '2001:1:2::3', + 'ttl': 3600, + 'comment': '', + }, + # (128, 42, 'AAAA', '*', '2001:1:2::4', 3600, None, None), + { + 'id': 128, + 'type': 'AAAA', + 'name': 'foo', + 'ipv6': '2001:1:2::4', + 'ttl': 3600, + 'comment': '', + }, + # (129, 42, 'MX', '', 'example.com', 3600, None, '10'), + { + 'id': 129, + 'type': 'MX', + 'ownername': '', + 'name': 'example.com', + 'pref': 10, + 'ttl': 3600, + 'comment': '', + }, + # (130, 42, 'CNAME', 'bar', 'example.org.', 10800, None, None), + { + 'id': 130, + 'type': 'CNAME', + 'name': 'bar', + 'cname': 'example.org.', + 'ttl': 10800, + 'comment': '', + }, +] + + +HOSTTECH_JSON_ZONE_LIST_RESULT = { + "data": [ + { + "id": 42, + "name": "example.com", + "email": "test@example.com", + "ttl": 10800, + "nameserver": "ns1.hosttech.ch", + "dnssec": False, + }, + ], +} + +HOSTTECH_JSON_ZONE_GET_RESULT = { + "data": { + "id": 42, + "name": "example.com", + "email": "test@example.com", + "ttl": 10800, + "nameserver": "ns1.hosttech.ch", + "dnssec": False, + "records": HOSTTECH_JSON_DEFAULT_ENTRIES, + } +} + +HOSTTECH_JSON_ZONE_RECORDS_GET_RESULT = { + "data": HOSTTECH_JSON_DEFAULT_ENTRIES, +} + + +original_exists = os.path.exists +original_access = os.access + + +def exists_mock(path, exists=True): + def exists(f): + if to_native(f) == path: + return exists + return original_exists(f) + + return exists + + +def access_mock(path, can_access=True): + def access(f, m, *args, **kwargs): + if to_native(f) == path: + return can_access + return original_access(f, m, *args, **kwargs) + + return access + + +def test_inventory_file_simple(mocker): + inventory_filename = "test.hosttech_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: foo + zone_name: example.com + filters: + type: A + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert im._inventory.hosts + assert 'example.com' in im._inventory.hosts + assert '*.example.com' in im._inventory.hosts + assert 'foo.example.com' not in im._inventory.hosts + assert 'bar.example.com' not in im._inventory.hosts + assert im._inventory.get_host('example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('*.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('example.com').get_vars()['ansible_host'] == '1.2.3.4' + assert im._inventory.get_host('*.example.com').get_vars()['ansible_host'] == '1.2.3.5' + assert len(im._inventory.groups['ungrouped'].hosts) == 2 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_collision(mocker): + inventory_filename = "test.hosttech_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: "{{ 'foo' }}" + zone_name: "{{ 'example' ~ '.com' }}" + filters: + type: + - A + - AAAA + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert im._inventory.hosts + assert 'example.com' in im._inventory.hosts + assert '*.example.com' in im._inventory.hosts + assert 'foo.example.com' in im._inventory.hosts + assert 'bar.example.com' not in im._inventory.hosts + assert im._inventory.get_host('example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('*.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('foo.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('example.com').get_vars()['ansible_host'] == '2001:1:2::3' + assert im._inventory.get_host('*.example.com').get_vars()['ansible_host'] == '1.2.3.5' + assert im._inventory.get_host('foo.example.com').get_vars()['ansible_host'] == '2001:1:2::4' + assert len(im._inventory.groups['ungrouped'].hosts) == 3 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_no_filter(mocker): + inventory_filename = "test.hosttech_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: foo + zone_id: 42 + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert im._inventory.hosts + assert 'example.com' in im._inventory.hosts + assert '*.example.com' in im._inventory.hosts + assert 'foo.example.com' in im._inventory.hosts + assert 'bar.example.com' in im._inventory.hosts + assert im._inventory.get_host('example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('*.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('foo.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('bar.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('example.com').get_vars()['ansible_host'] == '2001:1:2::3' + assert im._inventory.get_host('*.example.com').get_vars()['ansible_host'] == '1.2.3.5' + assert im._inventory.get_host('foo.example.com').get_vars()['ansible_host'] == '2001:1:2::4' + assert im._inventory.get_host('bar.example.com').get_vars()['ansible_host'] == 'example.org.' + assert len(im._inventory.groups['ungrouped'].hosts) == 4 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_invalid_zone_id(mocker): + inventory_filename = "test.hosttech_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: foo + zone_id: invalid + """)} + + open_url = OpenUrlProxy([ + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_missing_zone(mocker): + inventory_filename = "test.hosttech_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: foo + """)} + + open_url = OpenUrlProxy([ + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_zone_not_found(mocker): + inventory_filename = "test.hosttech_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: foo + zone_id: "{{ 11 + 12 }}" + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_unauthorized(mocker): + inventory_filename = "test.hosttech_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: foo + zone_id: 23 + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .result_json(dict(message="")), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_error(mocker): + inventory_filename = "test.hosttech_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: foo + zone_id: 42 + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .result_json({}), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_wrong_file(mocker): + inventory_filename = "test.hetznerdns.yml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: foo + """)} + + open_url = OpenUrlProxy([]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_no_file(mocker): + inventory_filename = "test.hosttech_dns.yml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + + open_url = OpenUrlProxy([]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename, False)) + mocker.patch('os.access', access_mock(inventory_filename, False)) + im = InventoryManager(loader=DictDataLoader({}), sources=inventory_filename) + + open_url.assert_is_done() + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/conversion/test_converter.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/conversion/test_converter.py new file mode 100644 index 000000000..e9911ad73 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/conversion/test_converter.py @@ -0,0 +1,260 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# 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 + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.dns.plugins.module_utils.conversion.base import ( + DNSConversionError, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.converter import ( + RecordConverter, +) + +from ansible_collections.community.dns.plugins.module_utils.record import ( + DNSRecord, +) + +from ..helper import ( + CustomProviderInformation, + CustomProvideOptions, +) + + +def test_user_api(): + converter = RecordConverter( + CustomProviderInformation(txt_record_handling='decoded', txt_character_encoding='decimal'), + CustomProvideOptions({'txt_transformation': 'api', 'txt_character_encoding': 'decimal'})) + assert converter.process_value_from_user('TXT', u'"xyz \\') == u'"xyz \\' + assert converter.process_values_from_user('TXT', [u'"xyz \\']) == [u'"xyz \\'] + assert converter.process_value_to_user('TXT', u'"xyz \\') == u'"xyz \\' + assert converter.process_values_to_user('TXT', [u'"xyz \\']) == [u'"xyz \\'] + + record = DNSRecord() + record.type = 'TXT' + + record.target = u'"xyz \\' + converter.process_from_user(record) + assert record.target == u'"xyz \\' + + record.target = u'"xyz \\' + converter.process_multiple_from_user([record]) + assert record.target == u'"xyz \\' + + record.target = u'"xyz \\' + converter.process_to_user(record) + assert record.target == u'"xyz \\' + + record.target = u'"xyz \\' + converter.process_multiple_to_user([record]) + assert record.target == u'"xyz \\' + + +def test_user_quoted(): + converter = RecordConverter( + CustomProviderInformation(txt_record_handling='decoded', txt_character_encoding='decimal'), + CustomProvideOptions({'txt_transformation': 'quoted', 'txt_character_encoding': 'decimal'})) + assert converter.process_value_from_user('TXT', u'hëllo " w\\195\\182rld"') == u'hëllo wörld' + assert converter.process_values_from_user('TXT', [u'hëllo " w\\195\\182rld"']) == [u'hëllo wörld'] + assert converter.process_value_to_user('TXT', u'hello wörld') == u'"hello w\\195\\182rld"' + assert converter.process_values_to_user('TXT', [u'hello wörld']) == [u'"hello w\\195\\182rld"'] + + record = DNSRecord() + record.type = 'TXT' + + record.target = u'hëllo " w\\195\\182rld"' + converter.process_from_user(record) + assert record.target == u'hëllo wörld' + + record.target = u'hëllo " w\\195\\182rld"' + converter.process_multiple_from_user([record]) + assert record.target == u'hëllo wörld' + + record.target = u'hello wörld' + converter.process_to_user(record) + assert record.target == u'"hello w\\195\\182rld"' + + record.target = u'hello wörld' + converter.process_multiple_to_user([record]) + assert record.target == u'"hello w\\195\\182rld"' + + record.target = u'"a\\o' + with pytest.raises(DNSConversionError) as exc: + converter.process_from_user(record) + print(exc.value.error_message) + assert exc.value.error_message == ( + u'While processing record from the user: A backslash must not be followed by "o" (index 4)' + ) + + +def test_user_unquoted(): + converter = RecordConverter( + CustomProviderInformation(txt_record_handling='decoded', txt_character_encoding='decimal'), + CustomProvideOptions({'txt_transformation': 'unquoted', 'txt_character_encoding': 'decimal'})) + assert converter.process_value_from_user('TXT', u'hello "wörl\\d"') == u'hello "wörl\\d"' + assert converter.process_values_from_user('TXT', [u'hello "wörl\\d"']) == [u'hello "wörl\\d"'] + assert converter.process_value_to_user('TXT', u'hello "wörl\\d"') == u'hello "wörl\\d"' + assert converter.process_values_to_user('TXT', [u'hello "wörl\\d"']) == [u'hello "wörl\\d"'] + + record = DNSRecord() + record.type = 'TXT' + + record.target = u'hello "wörl\\d"' + converter.process_from_user(record) + assert record.target == u'hello "wörl\\d"' + + record.target = u'hello "wörl\\d"' + converter.process_multiple_from_user([record]) + assert record.target == u'hello "wörl\\d"' + + record.target = u'hello "wörl\\d"' + converter.process_to_user(record) + assert record.target == u'hello "wörl\\d"' + + record.target = u'hello "wörl\\d"' + converter.process_multiple_to_user([record]) + assert record.target == u'hello "wörl\\d"' + + +def test_api_decoded(): + converter = RecordConverter( + CustomProviderInformation(txt_record_handling='decoded', txt_character_encoding='decimal'), + CustomProvideOptions({'txt_transformation': 'unquoted', 'txt_character_encoding': 'decimal'})) + record = DNSRecord() + record.type = 'TXT' + + record.target = u'"xyz \\' + record_2 = converter.clone_from_api(record) + assert record is not record_2 + assert record.target == u'"xyz \\' + assert record_2.target == u'"xyz \\' + converter.process_from_api(record) + assert record.target == u'"xyz \\' + + record.target = u'"xyz \\' + records = converter.clone_multiple_from_api([record]) + assert len(records) == 1 + assert record is not records[0] + assert record.target == u'"xyz \\' + assert records[0].target == u'"xyz \\' + converter.process_multiple_from_api([record]) + assert record.target == u'"xyz \\' + + record.target = u'"xyz \\' + record_2 = converter.clone_to_api(record) + assert record is not record_2 + assert record.target == u'"xyz \\' + assert record_2.target == u'"xyz \\' + converter.process_to_api(record) + assert record.target == u'"xyz \\' + + record.target = u'"xyz \\' + records = converter.clone_multiple_to_api([record]) + assert len(records) == 1 + assert record is not records[0] + assert record.target == u'"xyz \\' + assert records[0].target == u'"xyz \\' + converter.process_multiple_to_api([record]) + assert record.target == u'"xyz \\' + + +def test_api_encoded(): + converter = RecordConverter( + CustomProviderInformation(txt_record_handling='encoded', txt_character_encoding='decimal'), + CustomProvideOptions({'txt_transformation': 'unquoted', 'txt_character_encoding': 'decimal'})) + record = DNSRecord() + record.type = 'TXT' + + record.target = u'xyz " " \\\\\\195\\182' + record_2 = converter.clone_from_api(record) + assert record is not record_2 + assert record.target == u'xyz " " \\\\\\195\\182' + print(record_2.target) + assert record_2.target == u'xyz \\ö' + converter.process_from_api(record) + assert record.target == u'xyz \\ö' + + record.target = u'xyz " " \\\\\\195\\182' + records = converter.clone_multiple_from_api([record]) + assert len(records) == 1 + assert record is not records[0] + assert record.target == u'xyz " " \\\\\\195\\182' + assert records[0].target == u'xyz \\ö' + converter.process_multiple_from_api([record]) + assert record.target == u'xyz \\ö' + + record.target = u'xyz \\ö' + record_2 = converter.clone_to_api(record) + assert record is not record_2 + assert record.target == u'xyz \\ö' + assert record_2.target == u'"xyz \\\\\\195\\182"' + converter.process_to_api(record) + assert record.target == u'"xyz \\\\\\195\\182"' + + record.target = u'xyz \\ö' + records = converter.clone_multiple_to_api([record]) + assert len(records) == 1 + assert record is not records[0] + assert record.target == u'xyz \\ö' + assert records[0].target == u'"xyz \\\\\\195\\182"' + converter.process_multiple_to_api([record]) + assert record.target == u'"xyz \\\\\\195\\182"' + + record.target = u'"a' + with pytest.raises(DNSConversionError) as exc: + converter.process_from_api(record) + print(exc.value.error_message) + assert exc.value.error_message == ( + u'While processing record from API: Missing double quotation mark at the end of value' + ) + + +def test_api_encoded_no_octal(): + converter = RecordConverter( + CustomProviderInformation(txt_record_handling='encoded-no-octal', txt_character_encoding='decimal'), + CustomProvideOptions({'txt_transformation': 'unquoted', 'txt_character_encoding': 'decimal'})) + record = DNSRecord() + record.type = 'TXT' + + record.target = u'xyz " " \\\\\\195\\182' + record_2 = converter.clone_from_api(record) + assert record is not record_2 + assert record.target == u'xyz " " \\\\\\195\\182' + print(record_2.target) + assert record_2.target == u'xyz \\ö' + converter.process_from_api(record) + assert record.target == u'xyz \\ö' + + record.target = u'xyz " " \\\\\\195\\182' + records = converter.clone_multiple_from_api([record]) + assert len(records) == 1 + assert record is not records[0] + assert record.target == u'xyz " " \\\\\\195\\182' + assert records[0].target == u'xyz \\ö' + converter.process_multiple_from_api([record]) + assert record.target == u'xyz \\ö' + + record.target = u'xyz \\ö"' + record_2 = converter.clone_to_api(record) + assert record is not record_2 + assert record.target == u'xyz \\ö"' + assert record_2.target == u'"xyz \\\\ö\\""' + converter.process_to_api(record) + assert record.target == u'"xyz \\\\ö\\""' + + record.target = u'xyz \\ö"' + records = converter.clone_multiple_to_api([record]) + assert len(records) == 1 + assert record is not records[0] + assert record.target == u'xyz \\ö"' + assert records[0].target == u'"xyz \\\\ö\\""' + converter.process_multiple_to_api([record]) + assert record.target == u'"xyz \\\\ö\\""' diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/conversion/test_txt.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/conversion/test_txt.py new file mode 100644 index 000000000..e5984444b --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/conversion/test_txt.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# 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 + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import warnings + +import pytest + +from ansible_collections.community.dns.plugins.module_utils.conversion.base import ( + DNSConversionError, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.txt import ( + _get_utf8_length, + decode_txt_value, + encode_txt_value, +) + + +TEST_DECODE = [ + (r'', 'decimal', u''), + (r'"" "" ""', 'decimal', u''), + (r' "" "" ', 'decimal', u''), + (r'\032\033', 'decimal', u' !'), + (r'"\032" \033 ""', 'decimal', u' !'), + (r'\040\041', 'octal', u' !'), + (r'"\040" \041 ""', 'octal', u' !'), +] + + +@pytest.mark.parametrize("encoded, character_encoding, decoded", TEST_DECODE) +def test_decode(encoded, character_encoding, decoded): + decoded_ = decode_txt_value(encoded, character_encoding=character_encoding) + print(repr(decoded_), repr(decoded)) + assert decoded_ == decoded + + +TEST_GET_UTF8_LENGTH = [ + # See https://en.wikipedia.org/wiki/UTF-8#Examples + (0xC2, 2), # first byte of UTF-8 encoding of U+0024 + (0xC3, 2), # first byte of UTF-8 encoding of ä + (0xE0, 3), # first byte of UTF-8 encoding of U+0939 + (0xE2, 3), # first byte of UTF-8 encoding of U+20AC + (0xED, 3), # first byte of UTF-8 encoding of U+D55C + (0xF0, 4), # first byte of UTF-8 encoding of U+10348 + (0x00, 1), + (0xFF, 1), +] + + +@pytest.mark.parametrize("letter_code, length", TEST_GET_UTF8_LENGTH) +def test_get_utf8_length(letter_code, length): + length_ = _get_utf8_length(letter_code) + print(length_, length) + assert length_ == length + + +TEST_ENCODE_DECODE = [ + (u'', u'""', False, True, 'decimal'), + (u'', u'""', True, True, 'decimal'), + (u'Hi', u'Hi', False, True, 'decimal'), + (u'Hi', u'"Hi"', True, True, 'decimal'), + (u'"\\', u'\\\"\\\\', False, True, 'decimal'), + (u'"\\', u'"\\"\\\\"', True, True, 'decimal'), + (u'ä', u'ä', False, False, 'decimal'), + (u'ä', u'"ä"', True, False, 'decimal'), + (u'ä', u'\\195\\164', False, True, 'decimal'), + (u'ä', u'"\\195\\164"', True, True, 'decimal'), + (u'a b', u'"a b"', False, True, 'decimal'), + (u'a b', u'"a b"', True, True, 'decimal'), + ( + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuv' + u'wxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefg hijklmnopqrstu' + u'vwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + False, True, 'decimal' + ), + ( + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuv' + u'wxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + u'"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzA' + u'BCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012' + u'3456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefg" "hijklmnopqr' + u'stuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"', + True, True, 'decimal' + ), + ( + u'abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzA' + u'BCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012' + u'3456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstu' + u'vwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + u'"abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz' + u'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01' + u'23456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdef" ghijklmnopqr' + u'stuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + False, True, 'decimal' + ), + ( + u'abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzA' + u'BCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012' + u'3456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstu' + u'vwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + u'"abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz' + u'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01' + u'23456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdef" "ghijklmnopq' + u'rstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"', + True, True, 'decimal' + ), + ( + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefg', + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefg', + False, True, 'decimal' + ), + ( + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefg', + u'"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzA' + u'BCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012' + u'3456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefg"', + True, True, 'decimal' + ), + ( + # Avoid splitting up an decimal sequence into multiple TXT strings + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789aä', + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789a\\195 \\164', + False, True, 'decimal' + ), + ( + # Avoid splitting up a UTF-8 character into multiple TXT strings + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefä', + u'"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzA' + u'BCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012' + u'3456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdef" "ä"', + True, False, 'decimal' + ), + ( + # Avoid splitting up an octal sequence into multiple TXT strings + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789aä', + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789a\\303 \\244', + False, True, 'octal' + ), + ( + # Avoid splitting up a UTF-8 character into multiple TXT strings + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefä', + u'"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzA' + u'BCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012' + u'3456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdef" "ä"', + True, False, 'octal' + ), +] + + +@pytest.mark.parametrize("decoded, encoded, always_quote, use_character_encoding, character_encoding", TEST_ENCODE_DECODE) +def test_encode_decode(decoded, encoded, always_quote, use_character_encoding, character_encoding): + decoded_ = decode_txt_value(encoded, character_encoding=character_encoding) + print(repr(decoded_), repr(decoded)) + assert decoded_ == decoded + encoded_ = encode_txt_value(decoded, always_quote=always_quote, use_character_encoding=use_character_encoding, character_encoding=character_encoding) + print(repr(encoded_), repr(encoded)) + assert encoded_ == encoded + + +TEST_DECODE_ERROR = [ + (u'\\', 'decimal', 'Unexpected backslash at end of string'), + (u'\\a', 'decimal', 'A backslash must not be followed by "a" (index 2)'), + (u'\\0', 'decimal', 'The decimal sequence at the end requires 2 more digit(s)'), + (u'\\00', 'decimal', 'The decimal sequence at the end requires 1 more digit(s)'), + (u'\\0a', 'decimal', 'The decimal sequence at the end requires 1 more digit(s)'), + (u'\\0a0', 'decimal', 'The second letter of the decimal sequence at index 3 is not a decimal digit, but "a"'), + (u'\\00a', 'decimal', 'The third letter of the decimal sequence at index 4 is not a decimal digit, but "a"'), + (u'\\0', 'octal', 'The octal sequence at the end requires 2 more digit(s)'), + (u'\\00', 'octal', 'The octal sequence at the end requires 1 more digit(s)'), + (u'\\0a', 'octal', 'The octal sequence at the end requires 1 more digit(s)'), + (u'\\0a0', 'octal', 'The second letter of the octal sequence at index 3 is not a octal digit, but "a"'), + (u'\\00a', 'octal', 'The third letter of the octal sequence at index 4 is not a octal digit, but "a"'), + (u'a"b', 'decimal', 'Unexpected double quotation mark inside an unquoted block at position 2'), + (u'"', 'decimal', 'Missing double quotation mark at the end of value'), +] + + +@pytest.mark.parametrize("encoded, character_encoding, error", TEST_DECODE_ERROR) +def test_decode_error(encoded, character_encoding, error): + with pytest.raises(DNSConversionError) as exc: + decode_txt_value(encoded, character_encoding=character_encoding) + print(exc.value.error_message) + assert exc.value.error_message == error + + +def test_validation(): + with pytest.raises(ValueError) as exc: + decode_txt_value('foo', character_encoding='foo') + print(exc.value.args) + assert exc.value.args == ('character_encoding must be set to "octal" or "decimal"', ) + + with pytest.raises(ValueError) as exc: + encode_txt_value('foo', character_encoding='foo') + print(exc.value.args) + assert exc.value.args == ('character_encoding must be set to "octal" or "decimal"', ) + + +def test_deprecation(): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + encode_txt_value('foo') + + print(len(w), w) + assert len(w) >= 1 + warning = w[0] + assert issubclass(warning.category, DeprecationWarning) + msg = ( + 'The default value of the encode_txt_value parameter character_encoding is deprecated.' + ' Set explicitly to "octal" for the old behavior, or set to "decimal" for the new and correct behavior.' + ) + print(str(warning.message)) + assert msg == str(warning.message) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + encode_txt_value('foo', use_octal=True, character_encoding='octal') + + print(len(w), w) + assert len(w) >= 1 + warning = w[0] + assert issubclass(warning.category, DeprecationWarning) + msg = 'The encode_txt_value parameter use_octal is deprecated. Use use_character_encoding instead.' + print(str(warning.message)) + assert msg == str(warning.message) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + decode_txt_value('foo') + + print(len(w), w) + assert len(w) >= 1 + warning = w[0] + assert issubclass(warning.category, DeprecationWarning) + msg = ( + 'The default value of the decode_txt_value parameter character_encoding is deprecated.' + ' Set explicitly to "octal" for the old behavior, or set to "decimal" for the new and correct behavior.' + ) + print(str(warning.message)) + assert msg == str(warning.message) + + with pytest.raises(ValueError) as exc: + encode_txt_value('foo', use_octal=True, use_character_encoding=True) + print(exc.value.args) + assert exc.value.args == ('Cannot use both use_character_encoding and use_octal. Use only use_character_encoding!', ) diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/helper.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/helper.py new file mode 100644 index 000000000..aa0f8d6e1 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/helper.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# 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 + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +from ansible_collections.community.dns.plugins.module_utils.provider import ( + ProviderInformation, +) + + +class CustomProviderInformation(ProviderInformation): + def __init__(self, txt_record_handling='decoded', txt_character_encoding='decimal'): + super(CustomProviderInformation, self).__init__() + self._txt_record_handling = txt_record_handling + self._txt_character_encoding = txt_character_encoding + + def get_supported_record_types(self): + return ['A'] + + def get_zone_id_type(self): + return 'str' + + def get_record_id_type(self): + return 'str' + + def get_record_default_ttl(self): + return 300 + + def txt_record_handling(self): + return self._txt_record_handling + + def txt_character_encoding(self): + return self._txt_character_encoding + + +class CustomProvideOptions(object): + def __init__(self, option_dict): + self._option_dict = option_dict + + def get_option(self, name): + return self._option_dict.get(name) diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/hetzner/test_api.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/hetzner/test_api.py new file mode 100644 index 000000000..c7d440857 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/hetzner/test_api.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# 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 + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import MagicMock + +from ansible_collections.community.dns.plugins.module_utils.record import ( + DNSRecord, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, +) + +from ansible_collections.community.dns.plugins.module_utils.hetzner.api import ( + HetznerAPI, +) + + +def test_list_pagination(): + def get_1(url, query=None, must_have_content=True, expected=None): + assert url == 'https://example.com' + assert must_have_content == [200] + assert expected == [200] + assert query is not None + assert len(query) == 2 + assert query['per_page'] == 1 + assert query['page'] in [1, 2, 3] + if query['page'] < 3: + return { + 'data': [query['page']], + 'meta': { + 'pagination': { + 'page': query['page'], + 'per_page': 1, + 'last_page': 3, + 'total_entries': 2, + }, + }, + }, {'status': 200} + else: + return { + 'data': [], + 'meta': { + 'pagination': { + 'page': query['page'], + 'per_page': 1, + 'last_page': 3, + 'total_entries': 2, + }, + }, + }, {'status': 200} + + def get_2(url, query=None, must_have_content=True, expected=None): + assert url == 'https://example.com' + assert must_have_content == [200] + assert expected == ([200, 404] if query['page'] == 1 else [200]) + assert query is not None + assert len(query) == 3 + assert query['foo'] == 'bar' + assert query['per_page'] == 2 + assert query['page'] in [1, 2] + if query['page'] < 2: + return { + 'foobar': ['bar', 'baz'], + 'meta': { + 'pagination': { + 'page': query['page'], + 'per_page': 2, + 'last_page': 2, + 'total_entries': 3, + }, + }, + }, {'status': 200} + else: + return { + 'foobar': ['foo'], + 'meta': { + 'pagination': { + 'page': query['page'], + 'per_page': 2, + 'last_page': 2, + 'total_entries': 3, + }, + }, + }, {'status': 200} + + def get_3(url, query=None, must_have_content=True, expected=None): + assert url == 'https://example.com' + assert must_have_content == [200] + assert expected == [200, 404] + assert query is not None + assert len(query) == 2 + assert query['per_page'] == 100 + assert query['page'] == 1 + return None, {'status': 404} + + api = HetznerAPI(MagicMock(), '123') + + api._get = MagicMock(side_effect=get_1) + result = api._list_pagination('https://example.com', 'data', block_size=1, accept_404=False) + assert result == [1, 2] + + api._get = MagicMock(side_effect=get_2) + result = api._list_pagination('https://example.com', 'foobar', query=dict(foo='bar'), block_size=2, accept_404=True) + assert result == ['bar', 'baz', 'foo'] + + api._get = MagicMock(side_effect=get_3) + result = api._list_pagination('https://example.com', 'baz', accept_404=True) + assert result is None + + +def test_update_id_missing(): + api = HetznerAPI(MagicMock(), '123') + with pytest.raises(DNSAPIError) as exc: + api.update_record(1, DNSRecord()) + assert exc.value.args[0] == 'Need record ID to update record!' + + +def test_update_id_delete(): + api = HetznerAPI(MagicMock(), '123') + with pytest.raises(DNSAPIError) as exc: + api.delete_record(1, DNSRecord()) + assert exc.value.args[0] == 'Need record ID to delete record!' + + +def test_extract_error_message(): + api = HetznerAPI(MagicMock(), '123') + assert api._extract_error_message(None) == '' + assert api._extract_error_message('foo') == ' with data: foo' + assert api._extract_error_message(dict()) == ' with data: {}' + assert api._extract_error_message(dict(message='')) == " with data: {'message': ''}" + assert api._extract_error_message(dict(message='foo')) == ' with message "foo"' + assert api._extract_error_message(dict(message='foo', error='')) == ' with message "foo"' + assert api._extract_error_message(dict(message='foo', error=dict())) == ' with message "foo"' + assert api._extract_error_message(dict(message='foo', error=dict(code=123))) == ' (error code 123) with message "foo"' + assert api._extract_error_message(dict(message='foo', error=dict(message='baz'))) == ' with error message "baz" with message "foo"' + assert api._extract_error_message(dict(message='foo', error=dict(message='baz', code=123))) == ( + ' with error message "baz" (error code 123) with message "foo"' + ) + assert api._extract_error_message(dict(error=dict(message='baz', code=123))) == ' with error message "baz" (error code 123)' diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/hosttech/test_api.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/hosttech/test_api.py new file mode 100644 index 000000000..cd2ef3402 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/hosttech/test_api.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# 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 + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import MagicMock + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, +) + +from ansible_collections.community.dns.plugins.module_utils.hosttech import api + +from ..helper import ( + CustomProvideOptions, +) + + +def test_internal_error(): + option_provider = CustomProvideOptions({}) + with pytest.raises(DNSAPIError) as exc: + api.create_hosttech_api(option_provider, MagicMock()) + assert exc.value.args[0] == 'One of hosttech_token or both hosttech_username and hosttech_password must be provided!' + + +def test_wsdl_missing(): + option_provider = CustomProvideOptions({ + 'hosttech_username': 'foo', + 'hosttech_password': 'foo', + }) + old_value = api.HAS_LXML_ETREE + try: + api.HAS_LXML_ETREE = False + with pytest.raises(DNSAPIError) as exc: + api.create_hosttech_api(option_provider, MagicMock()) + assert exc.value.args[0] == 'Needs lxml Python module (pip install lxml)' + finally: + api.HAS_LXML_ETREE = old_value diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/hosttech/test_json_api.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/hosttech/test_json_api.py new file mode 100644 index 000000000..b1c6dad20 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/hosttech/test_json_api.py @@ -0,0 +1,381 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# 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 + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import MagicMock + +from ansible_collections.community.dns.plugins.module_utils.record import ( + DNSRecord, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, +) + +from ansible_collections.community.dns.plugins.module_utils.hosttech.json_api import ( + _create_record_from_json, + _record_to_json, + HostTechJSONAPI, +) + + +# The example JSONs for all record types are taken from https://api.ns1.hosttech.eu/api/documentation/ + +def test_AAAA(): + data = { + "id": 11, + "type": "AAAA", + "name": "www", + "ipv6": "2001:db8:1234::1", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 11 + assert record.type == 'AAAA' + assert record.prefix == 'www' + assert record.target == '2001:db8:1234::1' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + +def test_A(): + data = { + "id": 10, + "type": "A", + "name": "www", + "ipv4": "1.2.3.4", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 10 + assert record.type == 'A' + assert record.prefix == 'www' + assert record.target == '1.2.3.4' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + +def test_CAA(): + data = { + "id": 12, + "type": "CAA", + "name": "", + "flag": "0", + "tag": "issue", + "value": "letsencrypt.org", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 12 + assert record.type == 'CAA' + assert record.prefix is None + assert record.target == '0 issue "letsencrypt.org"' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + # We also accept versions without quotes: + record.target = '0 issue letsencrypt.org' + assert _record_to_json(record, include_id=True) == data + + record.target = '0\tissue "letsencrypt.org"' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0].startswith('Cannot split CAA record "0\tissue "letsencrypt.org"" into flag, tag and value: ') + + +def test_CNAME(): + data = { + "id": 13, + "type": "CNAME", + "name": "www", + "cname": "site.example.com", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 13 + assert record.type == 'CNAME' + assert record.prefix == 'www' + assert record.target == 'site.example.com' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + +def test_MX(): + data = { + "id": 14, + "type": "MX", + "ownername": "", + "name": "mail.example.com", + "pref": 10, + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 14 + assert record.type == 'MX' + assert record.prefix is None + assert record.target == '10 mail.example.com' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + record.target = 'mail.example.com' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0].startswith('Cannot split MX record "mail.example.com" into integer preference and name: ') + + record.target = 'x mail.example.com' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0].startswith('Cannot split MX record "x mail.example.com" into integer preference and name: ') + + +def test_NS(): + # WARNING: as opposed to documented on https://api.ns1.hosttech.eu/api/documentation/, + # NS records use 'targetname' and not 'name'! + data = { + "id": 14, + "type": "NS", + "ownername": "sub", + "targetname": "ns1.example.com", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 14 + assert record.type == 'NS' + assert record.prefix == 'sub' + assert record.target == 'ns1.example.com' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + +def test_PTR(): + data = { + "id": 15, + "type": "PTR", + "origin": "4.3.2.1", + "name": "smtp.example.com", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 15 + assert record.type == 'PTR' + assert record.prefix is None + assert record.target == '4.3.2.1 smtp.example.com' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + record.target = 'smtp.example.com' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0].startswith('Cannot split PTR record "smtp.example.com" into origin and name: ') + + +def test_SRV(): + data = { + "id": 16, + "type": "SRV", + "service": "_autodiscover._tcp", + "priority": 0, + "weight": 1, + "port": 443, + "target": "exchange.example.com", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 16 + assert record.type == 'SRV' + assert record.prefix == '_autodiscover._tcp' + assert record.target == '0 1 443 exchange.example.com' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + record.target = '1 443 exchange.example.com' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0].startswith( + 'Cannot split SRV record "1 443 exchange.example.com" into integer priority, integer weight, integer port and target: ') + + record.target = 'x 1 443 exchange.example.com' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0].startswith( + 'Cannot split SRV record "x 1 443 exchange.example.com" into integer priority, integer weight, integer port and target: ') + + record.target = '0 x 443 exchange.example.com' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0].startswith( + 'Cannot split SRV record "0 x 443 exchange.example.com" into integer priority, integer weight, integer port and target: ') + + record.target = '0 1 x exchange.example.com' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0].startswith( + 'Cannot split SRV record "0 1 x exchange.example.com" into integer priority, integer weight, integer port and target: ') + + +def test_TXT(): + data = { + "id": 17, + "type": "TXT", + "name": "", + "text": "v=spf1 ip4:1.2.3.4/32 -all", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 17 + assert record.type == 'TXT' + assert record.prefix is None + assert record.target == 'v=spf1 ip4:1.2.3.4/32 -all' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + +def test_TLSA(): + data = { + "id": 17, + "type": "TLSA", + "name": "", + "text": "0 0 1 d2abde240d7cd3ee6b4b28c54df034b97983a1d16e8a410e4561cb106618e971", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 17 + assert record.type == 'TLSA' + assert record.prefix is None + assert record.target == '0 0 1 d2abde240d7cd3ee6b4b28c54df034b97983a1d16e8a410e4561cb106618e971' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + +def test_unknown_records(): + data = { + "id": 17, + "type": "unknown", + "name": "", + "ttl": 3600, + "comment": "my first record", + } + with pytest.raises(DNSAPIError) as exc: + _create_record_from_json(data) + assert exc.value.args[0] == 'Cannot parse unknown record type: unknown' + + record = DNSRecord() + record.type = 'unknown' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0] == 'Cannot serialize unknown record type: unknown' + + +def test_list_pagination(): + def get_1(url, query=None, must_have_content=True, expected=None): + assert url == 'https://example.com' + assert must_have_content is True + assert expected == [200] + assert query is not None + assert len(query) == 2 + assert query['limit'] == 1 + assert query['offset'] in [0, 1, 2] + if query['offset'] < 2: + return {'data': [query['offset']]}, {} + else: + return {'data': []}, {} + + def get_2(url, query=None, must_have_content=True, expected=None): + assert url == 'https://example.com' + assert must_have_content is True + assert expected == [200] + assert query is not None + assert len(query) == 3 + assert query['foo'] == 'bar' + assert query['limit'] == 2 + assert query['offset'] in [0, 2] + if query['offset'] < 2: + return {'data': ['bar', 'baz']}, {} + else: + return {'data': ['foo']}, {} + + api = HostTechJSONAPI(MagicMock(), '123') + + api._get = MagicMock(side_effect=get_1) + result = api._list_pagination('https://example.com', block_size=1) + assert result == [0, 1] + + api._get = MagicMock(side_effect=get_2) + result = api._list_pagination('https://example.com', query=dict(foo='bar'), block_size=2) + assert result == ['bar', 'baz', 'foo'] + + +def test_update_id_missing(): + api = HostTechJSONAPI(MagicMock(), '123') + with pytest.raises(DNSAPIError) as exc: + api.update_record(1, DNSRecord()) + assert exc.value.args[0] == 'Need record ID to update record!' + + +def test_update_id_delete(): + api = HostTechJSONAPI(MagicMock(), '123') + with pytest.raises(DNSAPIError) as exc: + api.delete_record(1, DNSRecord()) + assert exc.value.args[0] == 'Need record ID to delete record!' + + +def test_extract_error_message(): + api = HostTechJSONAPI(MagicMock(), '123') + assert api._extract_error_message(None) == '' + assert api._extract_error_message('foo') == ' with data: foo' + assert api._extract_error_message(dict()) == ' with data: {}' + assert api._extract_error_message(dict(message='')) == " with data: {'message': ''}" + assert api._extract_error_message(dict(message='foo')) == ' with message "foo"' + assert api._extract_error_message(dict(message='foo', errors='')) == ' with message "foo"' + assert api._extract_error_message(dict(message='foo', errors=dict())) == ' with message "foo"' + assert api._extract_error_message(dict(message='foo', errors=dict(bar='baz'))) == ' with message "foo" (field "bar": baz)' + assert api._extract_error_message(dict(errors=dict(bar=['baz', 'bam'], arf='fra'))) == ' (field "arf": fra) (field "bar": baz; bam)' diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/module/test__utils.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/module/test__utils.py new file mode 100644 index 000000000..972b433e1 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/module/test__utils.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# 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 + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, +) + +from ansible_collections.community.dns.plugins.module_utils.module._utils import ( + normalize_dns_name, + get_prefix, +) + +from ..helper import ( + CustomProviderInformation, +) + + +def test_normalize_dns_name(): + assert normalize_dns_name('ExAMPLE.CoM.') == 'example.com' + assert normalize_dns_name('EXAMpLE.CoM') == 'example.com' + assert normalize_dns_name('Example.com') == 'example.com' + assert normalize_dns_name('.') == '' + assert normalize_dns_name(None) is None + + +def test_get_prefix(): + provider_information = CustomProviderInformation() + provider_information.get_supported_record_types() + assert get_prefix( + normalized_zone='example.com', normalized_record='example.com', provider_information=provider_information) == ('example.com', None) + assert get_prefix( + normalized_zone='example.com', normalized_record='www.example.com', provider_information=provider_information) == ('www.example.com', 'www') + assert get_prefix(normalized_zone='example.com', provider_information=provider_information) == ('example.com', None) + assert get_prefix(normalized_zone='example.com', prefix='', provider_information=provider_information) == ('example.com', None) + assert get_prefix(normalized_zone='example.com', prefix='.', provider_information=provider_information) == ('example.com', None) + assert get_prefix(normalized_zone='example.com', prefix='www', provider_information=provider_information) == ('www.example.com', 'www') + assert get_prefix(normalized_zone='example.com', prefix='www.', provider_information=provider_information) == ('www.example.com', 'www') + assert get_prefix(normalized_zone='example.com', prefix='wWw.', provider_information=provider_information) == ('www.example.com', 'www') + with pytest.raises(DNSAPIError): + get_prefix(normalized_zone='example.com', normalized_record='example.org', provider_information=provider_information) + with pytest.raises(DNSAPIError): + get_prefix(normalized_zone='example.com', normalized_record='wwwexample.com', provider_information=provider_information) diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/resolver_helper.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/resolver_helper.py new file mode 100644 index 000000000..e410d2076 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/resolver_helper.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# 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 + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import MagicMock + + +def mock_resolver(default_nameservers, nameserver_resolve_sequence): + def create_resolver(configure=True): + resolver = MagicMock() + resolver.nameservers = default_nameservers if configure else [] + + def mock_resolver_resolve(target, rdtype=None, lifetime=None): + resolver_index = tuple(sorted(resolver.nameservers)) + assert resolver_index in nameserver_resolve_sequence, 'No resolver sequence for {0}'.format(resolver_index) + resolve_sequence = nameserver_resolve_sequence[resolver_index] + assert len(resolve_sequence) > 0, 'Resolver sequence for {0} is empty'.format(resolver_index) + resolve_data = resolve_sequence[0] + del resolve_sequence[0] + + assert target == resolve_data['target'], 'target: {0!r} vs {1!r}'.format(target, resolve_data['target']) + assert rdtype == resolve_data.get('rdtype'), 'rdtype: {0!r} vs {1!r}'.format(rdtype, resolve_data.get('rdtype')) + assert lifetime == resolve_data['lifetime'], 'lifetime: {0!r} vs {1!r}'.format(lifetime, resolve_data['lifetime']) + + if 'raise' in resolve_data: + raise resolve_data['raise'] + + return resolve_data['result'] + + resolver.resolve = MagicMock(side_effect=mock_resolver_resolve) + return resolver + + return create_resolver + + +def mock_query_udp(call_sequence): + def udp(query, nameserver, **kwargs): + assert len(call_sequence) > 0, 'UDP query call sequence is empty' + call = call_sequence[0] + del call_sequence[0] + + assert query.question[0].name == call['query_target'], 'query_target: {0!r} vs {1!r}'.format(query.question[0].name, call['query_target']) + assert query.question[0].rdtype == call['query_type'], 'query_type: {0!r} vs {1!r}'.format(query.question[0].rdtype, call['query_type']) + assert nameserver == call['nameserver'], 'nameserver: {0!r} vs {1!r}'.format(nameserver, call['nameserver']) + assert kwargs == call['kwargs'], 'kwargs: {0!r} vs {1!r}'.format(kwargs, call['kwargs']) + + if 'raise' in call: + raise call['raise'] + + return call['result'] + + return udp + + +def create_mock_answer(rrset=None): + answer = MagicMock() + answer.rrset = rrset + return answer + + +def create_mock_response(rcode, authority=None, answer=None): + response = MagicMock() + response.rcode = MagicMock(return_value=rcode) + response.authority = authority or [] + response.answer = answer or [] + return response diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_argspec.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_argspec.py new file mode 100644 index 000000000..296e1647d --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_argspec.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# 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 + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ArgumentSpec, +) + + +def test_argspec(): + empty = ArgumentSpec() + non_empty = ArgumentSpec( + argument_spec=dict(test=dict(type='str'), foo=dict()), + required_together=[('test', 'foo')], + required_if=[('test', 'bar', ['foo'])], + required_one_of=[('test', 'foo')], + mutually_exclusive=[('test', 'foo')] + ) + empty.merge(non_empty) + assert empty.to_kwargs() == non_empty.to_kwargs() diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_json_api_helper.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_json_api_helper.py new file mode 100644 index 000000000..0d5d7bd06 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_json_api_helper.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# 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 + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import MagicMock + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, +) + +from ansible_collections.community.dns.plugins.module_utils.json_api_helper import ( + _get_header_value, + JSONAPIHelper, +) + + +def test_get_header_value(): + assert _get_header_value({'Return-Type': 1}, 'return-type') == 1 + assert _get_header_value({'Return-Type': 1}, 'Return-Type') == 1 + assert _get_header_value({'return-Type': 1}, 'Return-type') == 1 + assert _get_header_value({'return_type': 1}, 'Return-type') is None + + +def test_extract_error_message(): + api = JSONAPIHelper(MagicMock(), '123', 'https://example.com') + assert api._extract_error_message(None) == '' + assert api._extract_error_message('foo') == ' with data: foo' + assert api._extract_error_message(dict()) == ' with data: {}' + assert api._extract_error_message(dict(message='')) == " with data: {'message': ''}" + assert api._extract_error_message(dict(message='foo')) == " with data: {'message': 'foo'}" + + +def test_validate(): + module = MagicMock() + api = JSONAPIHelper(module, '123', 'https://example.com') + with pytest.raises(DNSAPIError) as exc: + api._validate() + assert exc.value.args[0] == 'Internal error: info needs to be provided' + + +def test_process_json_result(): + http_helper = MagicMock() + api = JSONAPIHelper(http_helper, '123', 'https://example.com') + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content=None, info=dict(status=401, url='https://example.com')) + assert exc.value.args[0] == 'Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content='{"message": ""}'.encode('utf-8'), info=dict(status=401, url='https://example.com')) + assert exc.value.args[0] == 'Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content='{"message": "foo"}'.encode('utf-8'), info=dict(status=401, url='https://example.com')) + assert exc.value.args[0] == 'Unauthorized: the authentication parameters are incorrect (HTTP status 401): foo' + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content=None, info=dict(status=403, url='https://example.com')) + assert exc.value.args[0] == 'Forbidden: you do not have access to this resource (HTTP status 403)' + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content='{"message": ""}'.encode('utf-8'), info=dict(status=403, url='https://example.com')) + assert exc.value.args[0] == 'Forbidden: you do not have access to this resource (HTTP status 403)' + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content='{"message": "foo"}'.encode('utf-8'), info=dict(status=403, url='https://example.com')) + assert exc.value.args[0] == 'Forbidden: you do not have access to this resource (HTTP status 403): foo' + + info = dict(status=200, url='https://example.com') + info['content-TYPE'] = 'application/json' + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content='not JSON'.encode('utf-8'), info=info) + assert exc.value.args[0] == 'GET https://example.com did not yield JSON data, but HTTP status code 200 with data: not JSON' + + info = dict(status=200, url='https://example.com') + info['Content-type'] = 'application/json' + r, i = api._process_json_result(content='not JSON'.encode('utf-8'), info=info, must_have_content=False) + assert r is None + info = dict(status=200, url='https://example.com') + info['Content-type'] = 'application/json' + assert i == info + + info = dict(status=404, url='https://example.com') + info['content-type'] = 'application/json' + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content='{}'.encode('utf-8'), info=info) + assert exc.value.args[0] == 'Expected successful HTTP status for GET https://example.com, but got HTTP status 404 (Not found) with data: {}' + + info = dict(status=404, url='https://example.com') + info['content-type'] = 'application/json' + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content='{}'.encode('utf-8'), info=info, expected=[200, 201]) + assert exc.value.args[0] == 'Expected HTTP status 200, 201 for GET https://example.com, but got HTTP status 404 (Not found) with data: {}' diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_names.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_names.py new file mode 100644 index 000000000..74dd83cc1 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_names.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# 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 + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.dns.plugins.module_utils.names import ( + join_labels, + is_ascii_label, + normalize_label, + split_into_labels, + InvalidDomainName, +) + + +TEST_IS_ASCII_LABEL = [ + ('asdf', True), + ('', True), + ('ä', False), + ('☹', False), + ('_dmarc', True), +] + + +@pytest.mark.parametrize("domain, result", TEST_IS_ASCII_LABEL) +def test_is_ascii_label(domain, result): + assert is_ascii_label(domain) == result + + +TEST_LABEL_SPLIT = [ + ('', [], ''), + ('.', [], '.'), + ('com', ['com'], ''), + ('com.', ['com'], '.'), + ('foo.bar', ['bar', 'foo'], ''), + ('foo.bar.', ['bar', 'foo'], '.'), + ('*.bar.', ['bar', '*'], '.'), + (u'☺.A', ['A', u'☺'], ''), +] + + +@pytest.mark.parametrize("domain, labels, tail", TEST_LABEL_SPLIT) +def test_split_into_labels(domain, labels, tail): + _labels, _tail = split_into_labels(domain) + assert _labels == labels + assert _tail == tail + assert join_labels(_labels, _tail) == domain + + +TEST_LABEL_SPLIT_ERRORS = [ + '.bar.', + '..bar', + '-bar', + 'bar-', +] + + +@pytest.mark.parametrize("domain", TEST_LABEL_SPLIT_ERRORS) +def test_split_into_labels_errors(domain): + with pytest.raises(InvalidDomainName): + split_into_labels(domain) + + +TEST_LABEL_JOIN = [ + ([], '', ''), + ([], '.', '.'), + (['a', 'b', 'c'], '', 'c.b.a'), + (['a', 'b', 'c'], '.', 'c.b.a.'), +] + + +@pytest.mark.parametrize("labels, tail, result", TEST_LABEL_JOIN) +def test_join_labels(labels, tail, result): + domain = join_labels(labels, tail) + assert domain == result + _labels, _tail = split_into_labels(domain) + assert _labels == labels + assert _tail == tail + + +TEST_LABEL_NORMALIZE = [ + ('', ''), + ('*', '*'), + ('foo', 'foo'), + ('Foo', 'foo'), + ('_dmarc', '_dmarc'), + (u'hëllö', 'xn--hll-jma1d'), + (u'食狮', 'xn--85x722f'), + (u'☺', 'xn--74h'), + (u'😉', 'xn--n28h'), +] + + +@pytest.mark.parametrize("label, normalized_label", TEST_LABEL_NORMALIZE) +def test_normalize_label(label, normalized_label): + print(normalize_label(label)) + assert normalize_label(label) == normalized_label diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_provider.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_provider.py new file mode 100644 index 000000000..79f688f60 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_provider.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2022, Felix Fontein <felix@fontein.de> +# 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 + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.dns.plugins.module_utils.provider import ( + ensure_type, +) + + +CHECK_TYPE_DATA = [ + ('asdf', 'str', 'asdf'), + (1, 'str', '1'), + ([], 'list', []), + ({}, 'dict', {}), + ('yes', 'bool', True), + ('5', 'int', 5), + ('5.10', 'float', 5.10), + ('foobar', 'raw', 'foobar'), +] + + +@pytest.mark.parametrize("input, type_name, output", CHECK_TYPE_DATA) +def test_is_ascii_label(input, type_name, output): + assert ensure_type(input, type_name) == output diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_record.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_record.py new file mode 100644 index 000000000..d3a8ff385 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_record.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# 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 + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +from ansible_collections.community.dns.plugins.module_utils.record import ( + format_ttl, + DNSRecord, + format_records_for_output, +) + + +def test_format_ttl(): + assert format_ttl(None) == 'default' + assert format_ttl(1) == '1s' + assert format_ttl(59) == '59s' + assert format_ttl(60) == '1m' + assert format_ttl(61) == '1m 1s' + assert format_ttl(3539) == '58m 59s' + assert format_ttl(3540) == '59m' + assert format_ttl(3541) == '59m 1s' + assert format_ttl(3599) == '59m 59s' + assert format_ttl(3600) == '1h' + assert format_ttl(3601) == '1h 1s' + assert format_ttl(3661) == '1h 1m 1s' + + +def test_format_records_for_output(): + A1 = DNSRecord() + A1.type = 'A' + A1.ttl = 300 + A1.target = '1.2.3.4' + A1.extra['foo'] = 'bar' + A2 = DNSRecord() + A2.type = 'A' + A2.ttl = 300 + A2.target = '1.2.3.5' + A3 = DNSRecord() + A3.type = 'A' + A3.ttl = 3600 + A3.target = '1.2.3.6' + AAAA = DNSRecord() + AAAA.type = 'AAAA' + AAAA.ttl = 600 + AAAA.target = '::1' + AAAA2 = DNSRecord() + AAAA2.type = 'AAAA' + AAAA2.ttl = None + AAAA2.target = '::2' + assert format_records_for_output([], 'foo', '') == { + 'record': 'foo', + 'prefix': '', + 'type': None, + 'ttl': None, + 'value': [], + } + assert format_records_for_output([A1, A2], 'foo', 'bar') == { + 'record': 'foo', + 'prefix': 'bar', + 'type': 'A', + 'ttl': 300, + 'value': ['1.2.3.4', '1.2.3.5'], + } + assert format_records_for_output([A3, A1], 'foo', None) == { + 'record': 'foo', + 'prefix': '', + 'type': 'A', + 'ttl': 300, + 'ttls': [300, 3600], + 'value': ['1.2.3.6', '1.2.3.4'], + } + assert format_records_for_output([A3], 'foo', None) == { + 'record': 'foo', + 'prefix': '', + 'type': 'A', + 'ttl': 3600, + 'value': ['1.2.3.6'], + } + assert format_records_for_output([AAAA], 'foo', None) == { + 'record': 'foo', + 'prefix': '', + 'type': 'AAAA', + 'ttl': 600, + 'value': ['::1'], + } + assert format_records_for_output([A3, AAAA], 'foo', None) == { + 'record': 'foo', + 'prefix': '', + 'type': 'A', + 'ttl': 600, + 'ttls': [600, 3600], + 'value': ['1.2.3.6', '::1'], + } + assert format_records_for_output([AAAA, A3], 'foo', None) == { + 'record': 'foo', + 'prefix': '', + 'type': 'A', + 'ttl': 600, + 'ttls': [600, 3600], + 'value': ['::1', '1.2.3.6'], + } + assert format_records_for_output([AAAA2], 'foo', None) == { + 'record': 'foo', + 'prefix': '', + 'type': 'AAAA', + 'ttl': None, + 'value': ['::2'], + } + print(format_records_for_output([AAAA2, AAAA], 'foo', None)) + assert format_records_for_output([AAAA2, AAAA], 'foo', None) == { + 'record': 'foo', + 'prefix': '', + 'type': 'AAAA', + 'ttl': None, + 'ttls': [None, 600], + 'value': ['::2', '::1'], + } + + +def test_record_str_repr(): + A1 = DNSRecord() + A1.prefix = None + A1.type = 'A' + A1.ttl = 300 + A1.target = '1.2.3.4' + assert str(A1) == 'DNSRecord(type: A, prefix: (none), target: "1.2.3.4", ttl: 5m)' + assert repr(A1) == 'DNSRecord(type: A, prefix: (none), target: "1.2.3.4", ttl: 5m)' + A2 = DNSRecord() + A2.id = 23 + A2.prefix = 'bar' + A2.type = 'A' + A2.ttl = 1 + A2.target = '' + A2.extra['foo'] = 'bar' + assert str(A2) == 'DNSRecord(id: 23, type: A, prefix: "bar", target: "", ttl: 1s, extra: {\'foo\': \'bar\'})' + assert repr(A2) == 'DNSRecord(id: 23, type: A, prefix: "bar", target: "", ttl: 1s, extra: {\'foo\': \'bar\'})' diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_resolver.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_resolver.py new file mode 100644 index 000000000..1f51601e0 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_resolver.py @@ -0,0 +1,905 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# 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 + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import MagicMock, patch + +from ansible_collections.community.dns.plugins.module_utils import resolver + +from ansible_collections.community.dns.plugins.module_utils.resolver import ( + ResolveDirectlyFromNameServers, + ResolverError, + assert_requirements_present, +) + +from .resolver_helper import ( + mock_resolver, + mock_query_udp, + create_mock_answer, + create_mock_response, +) + +# We need dnspython +dns = pytest.importorskip('dns') + + +def test_assert_requirements_present(): + class ModuleFailException(Exception): + pass + + def fail_json(**kwargs): + raise ModuleFailException(kwargs) + + module = MagicMock() + module.fail_json = MagicMock(side_effect=fail_json) + + orig_importerror = resolver.DNSPYTHON_IMPORTERROR + resolver.DNSPYTHON_IMPORTERROR = None + assert_requirements_present(module) + + resolver.DNSPYTHON_IMPORTERROR = 'asdf' + with pytest.raises(ModuleFailException) as exc: + assert_requirements_present(module) + + assert 'dnspython' in exc.value.args[0]['msg'] + assert 'asdf' == exc.value.args[0]['exception'] + + resolver.DNSPYTHON_IMPORTERROR = orig_importerror + + +def test_lookup_ns_names(): + resolver = mock_resolver(['1.1.1.1'], {}) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.org.'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com.'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '3.3.3.3', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 60, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME, 'foo.bar.'), + )], authority=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com.'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + resolver = ResolveDirectlyFromNameServers(always_ask_default_resolver=False) + # Use default resolver + ns, cname = resolver._lookup_ns_names(dns.name.from_unicode(u'example.com')) + assert ns == ['ns.example.com.', 'ns.example.org.'] + assert cname is None + # Provide nameserver IPs + ns, cname = resolver._lookup_ns_names(dns.name.from_unicode(u'example.com'), nameserver_ips=['3.3.3.3', '1.1.1.1']) + assert ns == ['ns.example.com.'] + assert cname == dns.name.from_unicode(u'foo.bar.') + # Provide empty nameserver list + with pytest.raises(ResolverError) as exc: + resolver._lookup_ns_names(dns.name.from_unicode(u'example.com'), nameservers=[]) + assert exc.value.args[0] == 'Have neither nameservers nor nameserver IPs' + + +def test_resolver(): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.AAAA, '1:2::3'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.AAAA, '2:3::4'), + )), + }, + { + 'target': 'ns.example.org', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '4.4.4.4'), + )), + }, + { + 'target': 'ns.example.org', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + { + 'target': 'ns.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '2.2.2.2'), + )), + }, + { + 'target': 'ns.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('1:2::3', '2:3::4', '3.3.3.3'): [ + { + 'target': dns.name.from_unicode(u'example.org'), + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '1.2.3.4'), + )), + }, + ], + ('4.4.4.4', ): [ + { + 'target': dns.name.from_unicode(u'example.org'), + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '1.2.3.5'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'www.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + ), dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME, 'example.org') + )]), + }, + { + 'query_target': dns.name.from_unicode(u'org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.org'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.org'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + resolver = ResolveDirectlyFromNameServers() + assert resolver.resolve_nameservers('example.com', resolve_addresses=True) == ['1:2::3', '2:3::4', '3.3.3.3'] + # www.example.com is a CNAME for example.org + rrset_dict = resolver.resolve('www.example.com') + assert sorted(rrset_dict.keys()) == ['ns.example.com', 'ns.example.org'] + rrset = rrset_dict['ns.example.com'] + assert len(rrset) == 1 + assert rrset.name == dns.name.from_unicode(u'example.org', origin=None) + assert rrset.rdtype == dns.rdatatype.A + assert rrset[0].to_text() == u'1.2.3.4' + rrset = rrset_dict['ns.example.org'] + assert len(rrset) == 1 + assert rrset.name == dns.name.from_unicode(u'example.org', origin=None) + assert rrset.rdtype == dns.rdatatype.A + assert rrset[0].to_text() == u'1.2.3.5' + # The following results should be cached: + assert resolver.resolve_nameservers('com', resolve_addresses=True) == ['2.2.2.2'] + assert resolver.resolve_nameservers('org') == ['ns.org'] + assert resolver.resolve_nameservers('example.com') == ['ns.example.com'] + assert resolver.resolve_nameservers('example.org') == ['ns.example.com', 'ns.example.org'] + + +def test_timeout_handling(): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'raise': dns.exception.Timeout(timeout=10), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.exception.Timeout(timeout=10), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + { + 'target': 'ns.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'raise': dns.exception.Timeout(timeout=10), + }, + { + 'target': 'ns.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'raise': dns.exception.Timeout(timeout=10), + }, + { + 'target': 'ns.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '2.2.2.2'), + )), + }, + { + 'target': 'ns.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'raise': dns.exception.Timeout(timeout=10), + }, + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, authority=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + resolver = ResolveDirectlyFromNameServers() + assert resolver.resolve_nameservers('example.com', resolve_addresses=True) == ['3.3.3.3'] + # The following results should be cached: + assert resolver.resolve_nameservers('com') == ['ns.com'] + assert resolver.resolve_nameservers('com', resolve_addresses=True) == ['2.2.2.2'] + assert resolver.resolve_nameservers('example.com') == ['ns.example.com'] + assert resolver.resolve_nameservers('example.com', resolve_addresses=True) == ['3.3.3.3'] + + +def test_timeout_failure(): + resolver = mock_resolver(['1.1.1.1'], {}) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'raise': dns.exception.Timeout(timeout=1), + }, + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'raise': dns.exception.Timeout(timeout=2), + }, + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'raise': dns.exception.Timeout(timeout=3), + }, + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'raise': dns.exception.Timeout(timeout=4), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with pytest.raises(dns.exception.Timeout) as exc: + resolver = ResolveDirectlyFromNameServers() + resolver.resolve_nameservers('example.com') + assert exc.value.kwargs['timeout'] == 4 + + +def test_error_nxdomain(): + resolver = mock_resolver(['1.1.1.1'], {}) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NXDOMAIN), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with pytest.raises(dns.resolver.NXDOMAIN) as exc: + resolver = ResolveDirectlyFromNameServers() + resolver.resolve_nameservers('example.com') + assert exc.value.kwargs['qnames'] == [dns.name.from_unicode(u'com')] + + +def test_error_servfail(): + resolver = mock_resolver(['1.1.1.1'], {}) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.SERVFAIL), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with pytest.raises(ResolverError) as exc: + resolver = ResolveDirectlyFromNameServers() + resolver.resolve_nameservers('example.com') + assert exc.value.args[0] == 'Error SERVFAIL while querying 1.1.1.1 with query get NS for "com."' + + +def test_no_response(): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '5.5.5.5'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + { + 'target': 'ns2.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '4.4.4.4'), + )), + }, + { + 'target': 'ns2.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', '5.5.5.5'): [ + { + 'target': dns.name.from_unicode(u'example.com'), + 'lifetime': 10, + 'result': create_mock_answer(), + }, + ], + ('4.4.4.4', ): [ + { + 'target': dns.name.from_unicode(u'example.com'), + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns2.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + resolver = ResolveDirectlyFromNameServers() + rrset_dict = resolver.resolve('example.com') + assert sorted(rrset_dict.keys()) == ['ns.example.com', 'ns2.example.com'] + assert rrset_dict['ns.example.com'] is None + assert rrset_dict['ns2.example.com'] is None + # Verify nameserver IPs + assert resolver.resolve_nameservers('example.com') == ['ns.example.com', 'ns2.example.com'] + assert resolver.resolve_nameservers('example.com', resolve_addresses=True) == ['3.3.3.3', '4.4.4.4', '5.5.5.5'] + + +def test_cname_loop(): + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.com', + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '2.2.2.2'), + )), + }, + { + 'target': 'ns.example.com', + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.org', + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '2.2.3.3'), + )), + }, + { + 'target': 'ns.example.org', + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '4.4.4.4'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'www.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + ), dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME, 'example.org') + )]), + }, + { + 'query_target': dns.name.from_unicode(u'org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.org'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.org'), + ), dns.rrset.from_rdata( + 'example.org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME, 'www.example.com') + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + resolver = ResolveDirectlyFromNameServers() + with pytest.raises(ResolverError) as exc: + resolver.resolve('www.example.com') + assert exc.value.args[0] == 'Found CNAME loop starting at www.example.com' + + +def test_resolver_non_default(): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '2.2.2.2'), + )), + }, + { + 'target': 'ns.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + { + 'target': 'ns.org', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '2.2.3.3'), + )), + }, + { + 'target': 'ns.org', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + { + 'target': 'ns.example.org', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '4.4.4.4'), + )), + }, + { + 'target': 'ns.example.org', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', ): [ + { + 'target': dns.name.from_unicode(u'example.org'), + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '1.2.3.4'), + )), + }, + ], + ('4.4.4.4', ): [ + { + 'target': dns.name.from_unicode(u'example.org'), + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '1.2.3.4'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '2.2.2.2', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'www.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '3.3.3.3', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + ), dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME, 'example.org') + )]), + }, + { + 'query_target': dns.name.from_unicode(u'org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.org'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '2.2.3.3', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.org'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + resolver = ResolveDirectlyFromNameServers(always_ask_default_resolver=False) + assert resolver.resolve_nameservers('example.com') == ['ns.example.com'] + # www.example.com is a CNAME for example.org + rrset_dict = resolver.resolve('www.example.com') + assert sorted(rrset_dict.keys()) == ['ns.example.com', 'ns.example.org'] + rrset = rrset_dict['ns.example.com'] + assert len(rrset) == 1 + assert rrset.name == dns.name.from_unicode(u'example.org', origin=None) + assert rrset.rdtype == dns.rdatatype.A + assert rrset[0].to_text() == u'1.2.3.4' + rrset = rrset_dict['ns.example.org'] + assert len(rrset) == 1 + assert rrset.name == dns.name.from_unicode(u'example.org', origin=None) + assert rrset.rdtype == dns.rdatatype.A + assert rrset[0].to_text() == u'1.2.3.4' + # The following results should be cached: + assert resolver.resolve_nameservers('com') == ['ns.com'] + print(resolver.resolve_nameservers('example.com', resolve_addresses=True)) + assert resolver.resolve_nameservers('example.com', resolve_addresses=True) == ['3.3.3.3'] + print(resolver.resolve_nameservers('example.org', resolve_addresses=True)) + assert resolver.resolve_nameservers('example.org', resolve_addresses=True) == ['3.3.3.3', '4.4.4.4'] diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_wsdl.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_wsdl.py new file mode 100644 index 000000000..14dd66428 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_wsdl.py @@ -0,0 +1,283 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# 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 sys +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import MagicMock + +lxmletree = pytest.importorskip("lxml.etree") + +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.dns.plugins.module_utils.wsdl import ( + Parser, + Composer, +) + + +def test_composer_generation(): + composer = Composer(MagicMock(), api='https://example.com/api') + composer.add_simple_command( + 'test', + int_value=42, + str_value='bar', + list_value=[1, 2, 3], + dict_value={ + 'hello': 'world', + 'list': [2, 3, 5, 7], + } + ) + command = to_native(lxmletree.tostring(composer._root, pretty_print=True)).splitlines() + + print(command) + + expected_lines = [ + ' <SOAP-ENV:Header/>', + ' <SOAP-ENV:Body>', + ' <ns0:test xmlns:ns0="https://example.com/api">', + ' <int_value xsi:type="xsd:int">42</int_value>', + ' <str_value xsi:type="xsd:string">bar</str_value>', + ' <list_value xsi:type="SOAP-ENC:Array">', + ' <item xsi:type="xsd:int">1</item>', + ' <item xsi:type="xsd:int">2</item>', + ' <item xsi:type="xsd:int">3</item>', + ' </list_value>', + ' <dict_value xmlns:ns0="http://xml.apache.org/xml-soap" xsi:type="ns0:Map">', + ' <item>', + ' <key xsi:type="xsd:string">hello</key>', + ' <value xsi:type="xsd:string">world</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">list</key>', + ' <value xsi:type="SOAP-ENC:Array">', + ' <item xsi:type="xsd:int">2</item>', + ' <item xsi:type="xsd:int">3</item>', + ' <item xsi:type="xsd:int">5</item>', + ' <item xsi:type="xsd:int">7</item>', + ' </value>', + ' </item>', + ' </dict_value>', + ' </ns0:test>', + ' </SOAP-ENV:Body>', + '</SOAP-ENV:Envelope>', + ] + + if sys.version_info < (3, 7): + assert sorted(command[1:]) == sorted(expected_lines) + else: + assert command[1:] == expected_lines + + for part in [ + '<SOAP-ENV:Envelope', + ' xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"', + ' xmlns:xsd="http://www.w3.org/2001/XMLSchema"', + ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"', + ' xmlns:ns2="auth"', + ' xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/"', + ' SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"', + ]: + assert part in command[0] + + +def test_parsing(): + input = '\n'.join([ + '<?xml version="1.0" encoding="UTF-8"?>', + ''.join([ + '<SOAP-ENV:Envelope', + ' xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"', + ' xmlns:ns1="https://example.com/api"', + ' xmlns:xsd="http://www.w3.org/2001/XMLSchema"', + ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"', + ' xmlns:ns2="http://xml.apache.org/xml-soap"', + ' xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/"', + ' SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"', + '>', + ]), + ' <SOAP-ENV:Header>', + ' <ns1:authenticateResponse>', + ' <return xsi:type="xsd:boolean">true</return>', + ' </ns1:authenticateResponse>', + ' </SOAP-ENV:Header>', + ' <SOAP-ENV:Body>', + ' <ns1:getZoneResponse>', + ' <return xsi:type="ns2:Map">', + ' <item>', + ' <key xsi:type="xsd:string">id</key>', + ' <value xsi:type="xsd:int">1</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">user</key>', + ' <value xsi:type="xsd:int">2</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">name</key>', + ' <value xsi:type="xsd:string">example.com</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">email</key>', + ' <value xsi:type="xsd:string">info@example.com</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">ttl</key>', + ' <value xsi:type="xsd:int">10800</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">nameserver</key>', + ' <value xsi:type="xsd:string">ns1.hostserv.eu</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">serial</key>', + ' <value xsi:type="xsd:string">1234567890</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">serialLastUpdate</key>', + ' <value xsi:type="xsd:int">0</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">refresh</key>', + ' <value xsi:type="xsd:int">7200</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">retry</key>', + ' <value xsi:type="xsd:int">120</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">expire</key>', + ' <value xsi:type="xsd:int">1234567</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">template</key>', + ' <value xsi:nil="true"/>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">ns3</key>', + ' <value xsi:type="xsd:int">1</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">records</key>', + ' <value SOAP-ENC:arrayType="ns2:Map[2]" xsi:type="SOAP-ENC:Array">', + ' <item xsi:type="ns2:Map">', + ' <item>', + ' <key xsi:type="xsd:string">id</key>', + ' <value xsi:type="xsd:int">3</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">zone</key>', + ' <value xsi:type="xsd:int">4</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">type</key>', + ' <value xsi:type="xsd:string">A</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">prefix</key>', + ' <value xsi:type="xsd:string"></value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">target</key>', + ' <value xsi:type="xsd:string">1.2.3.4</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">ttl</key>', + ' <value xsi:type="xsd:int">3600</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">comment</key>', + ' <value xsi:nil="true"/>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">priority</key>', + ' <value xsi:nil="true"/>', + ' </item>', + ' </item>', + ' <item xsi:type="ns2:Map">', + ' <item>', + ' <key xsi:type="xsd:string">id</key>', + ' <value xsi:type="xsd:int">5</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">zone</key>', + ' <value xsi:type="xsd:int">4</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">type</key>', + ' <value xsi:type="xsd:string">A</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">prefix</key>', + ' <value xsi:type="xsd:string">*</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">target</key>', + ' <value xsi:type="xsd:string">1.2.3.5</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">ttl</key>', + ' <value xsi:type="xsd:int">3600</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">comment</key>', + ' <value xsi:nil="true"/>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">priority</key>', + ' <value xsi:nil="true"/>', + ' </item>', + ' </item>', + ' </value>', + ' </item>', + ' </return>', + ' </ns1:getZoneResponse>', + ' </SOAP-ENV:Body>', + '</SOAP-ENV:Envelope>', + ]).encode('utf-8') + + parser = Parser('https://example.com/api', lxmletree.fromstring(input)) + assert parser.get_header('authenticateResponse') is True + assert len(parser._header) == 1 + assert parser.get_result('getZoneResponse') == { + 'id': 1, + 'user': 2, + 'name': 'example.com', + 'email': 'info@example.com', + 'ttl': 10800, + 'nameserver': 'ns1.hostserv.eu', + 'serial': '1234567890', + 'serialLastUpdate': 0, + 'refresh': 7200, + 'retry': 120, + 'expire': 1234567, + 'template': None, + 'ns3': 1, + 'records': [ + { + 'id': 3, + 'zone': 4, + 'type': 'A', + 'prefix': None, + 'target': '1.2.3.4', + 'ttl': 3600, + 'comment': None, + 'priority': None, + }, + { + 'id': 5, + 'zone': 4, + 'type': 'A', + 'prefix': '*', + 'target': '1.2.3.5', + 'ttl': 3600, + 'comment': None, + 'priority': None, + }, + ], + } + assert len(parser._body) == 1 diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_zone.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_zone.py new file mode 100644 index 000000000..df16adf2a --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_zone.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# 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 + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +from ansible_collections.community.dns.plugins.module_utils.record import ( + DNSRecord, +) + +from ansible_collections.community.dns.plugins.module_utils.zone import ( + DNSZone, + DNSZoneWithRecords, +) + + +def test_zone_str_repr(): + Z1 = DNSZone('foo') + assert str(Z1) == 'DNSZone(name: foo, info: {})' + assert repr(Z1) == 'DNSZone(name: foo, info: {})' + Z2 = DNSZone('foo') + Z2.id = 42 + Z2.info['foo'] = 'bar' + assert str(Z2) == "DNSZone(id: 42, name: foo, info: {'foo': 'bar'})" + assert repr(Z2) == "DNSZone(id: 42, name: foo, info: {'foo': 'bar'})" + + +def test_zone_with_records_str_repr(): + Z1 = DNSZone('foo') + Z2 = DNSZone('foo') + Z2.id = 42 + A1 = DNSRecord() + A1.prefix = None + A1.type = 'A' + A1.ttl = 300 + A1.target = '1.2.3.4' + A2 = DNSRecord() + A2.id = 23 + A2.prefix = 'bar' + A2.type = 'A' + A2.ttl = 1 + A2.target = '' + A2.extra['foo'] = 23 + ZZ1 = DNSZoneWithRecords(Z1, [A1]) + ZZ2 = DNSZoneWithRecords(Z2, [A1, A2]) + assert str(ZZ1) == '(DNSZone(name: foo, info: {}), [DNSRecord(type: A, prefix: (none), target: "1.2.3.4", ttl: 5m)])' + assert repr(ZZ1) == 'DNSZoneWithRecords(DNSZone(name: foo, info: {}), [DNSRecord(type: A, prefix: (none), target: "1.2.3.4", ttl: 5m)])' + assert str(ZZ2) == ( + '(DNSZone(id: 42, name: foo, info: {}), [DNSRecord(type: A, prefix: (none), target: "1.2.3.4", ttl: 5m),' + ' DNSRecord(id: 23, type: A, prefix: "bar", target: "", ttl: 1s, extra: {\'foo\': 23})])' + ) + assert repr(ZZ2) == ( + 'DNSZoneWithRecords(DNSZone(id: 42, name: foo, info: {}), [DNSRecord(type: A, prefix: (none), target: "1.2.3.4", ttl: 5m),' + ' DNSRecord(id: 23, type: A, prefix: "bar", target: "", ttl: 1s, extra: {\'foo\': 23})])' + ) diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/hetzner.py b/ansible_collections/community/dns/tests/unit/plugins/modules/hetzner.py new file mode 100644 index 000000000..e28edf736 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/hetzner.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# 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 + + +HETZNER_DEFAULT_ZONE = { + 'id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + 'legacy_dns_host': 'string', + 'legacy_ns': ['foo', 'bar'], + 'name': 'example.com', + 'ns': ['string'], + 'owner': 'Example', + 'paused': True, + 'permission': 'string', + 'project': 'string', + 'registrar': 'string', + 'status': 'verified', + 'ttl': 10800, + 'verified': '2021-07-09T11:18:37Z', + 'records_count': 0, + 'is_secondary_dns': True, + 'txt_verification': { + 'name': 'string', + 'token': 'string', + }, +} + +HETZNER_JSON_DEFAULT_ENTRIES = [ + { + 'id': '125', + 'type': 'A', + 'name': '@', + 'value': '1.2.3.4', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '126', + 'type': 'A', + 'name': '*', + 'value': '1.2.3.5', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '127', + 'type': 'AAAA', + 'name': '@', + 'value': '2001:1:2::3', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '128', + 'type': 'AAAA', + 'name': '*', + 'value': '2001:1:2::4', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '129', + 'type': 'MX', + 'name': '@', + 'value': '10 example.com', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '130', + 'type': 'NS', + 'name': '@', + 'value': 'helium.ns.hetzner.de.', + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '131', + 'type': 'NS', + 'name': '@', + 'value': 'hydrogen.ns.hetzner.com.', + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '132', + 'type': 'NS', + 'name': '@', + 'value': 'oxygen.ns.hetzner.com.', + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '200', + 'type': 'SOA', + 'name': '@', + 'value': 'hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600', + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '201', + 'type': 'TXT', + 'name': 'foo', + 'value': u'bär " \\"with quotes\\"" " " "(use \\\\ to escape)"', + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, +] + +HETZNER_JSON_ZONE_LIST_RESULT = { + 'zones': [ + HETZNER_DEFAULT_ZONE, + ], +} + +HETZNER_JSON_ZONE_GET_RESULT = { + 'zone': HETZNER_DEFAULT_ZONE, +} + +HETZNER_JSON_ZONE_RECORDS_GET_RESULT = { + 'records': HETZNER_JSON_DEFAULT_ENTRIES, +} diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/hosttech.py b/ansible_collections/community/dns/tests/unit/plugins/modules/hosttech.py new file mode 100644 index 000000000..696cf7d01 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/hosttech.py @@ -0,0 +1,463 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# 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 + +try: + import lxml.etree +except ImportError: + # should be handled in module importing this one + pass + + +HOSTTECH_WSDL_DEFAULT_ENTRIES = [ + (125, 42, 'A', '', '1.2.3.4', 3600, None, None), + (126, 42, 'A', '*', '1.2.3.5', 3600, None, None), + (127, 42, 'AAAA', '', '2001:1:2::3', 3600, None, None), + (128, 42, 'AAAA', '*', '2001:1:2::4', 3600, None, None), + (129, 42, 'MX', '', 'example.com', 3600, None, '10'), + (130, 42, 'NS', '', 'ns3.hostserv.eu', 10800, None, None), + (131, 42, 'NS', '', 'ns2.hostserv.eu', 10800, None, None), + (132, 42, 'NS', '', 'ns1.hostserv.eu', 10800, None, None), +] + +HOSTTECH_JSON_DEFAULT_ENTRIES = [ + # (125, 42, 'A', '', '1.2.3.4', 3600, None, None), + { + 'id': 125, + 'type': 'A', + 'name': '', + 'ipv4': '1.2.3.4', + 'ttl': 3600, + 'comment': '', + }, + # (126, 42, 'A', '*', '1.2.3.5', 3600, None, None), + { + 'id': 126, + 'type': 'A', + 'name': '*', + 'ipv4': '1.2.3.5', + 'ttl': 3600, + 'comment': '', + }, + # (127, 42, 'AAAA', '', '2001:1:2::3', 3600, None, None), + { + 'id': 127, + 'type': 'AAAA', + 'name': '', + 'ipv6': '2001:1:2::3', + 'ttl': 3600, + 'comment': '', + }, + # (128, 42, 'AAAA', '*', '2001:1:2::4', 3600, None, None), + { + 'id': 128, + 'type': 'AAAA', + 'name': '*', + 'ipv6': '2001:1:2::4', + 'ttl': 3600, + 'comment': '', + }, + # (129, 42, 'MX', '', 'example.com', 3600, None, '10'), + { + 'id': 129, + 'type': 'MX', + 'ownername': '', + 'name': 'example.com', + 'pref': 10, + 'ttl': 3600, + 'comment': '', + }, + # (130, 42, 'NS', '', 'ns3.hostserv.eu', 10800, None, None), + { + 'id': 130, + 'type': 'NS', + 'ownername': '', + 'targetname': 'ns3.hostserv.eu', + 'ttl': 10800, + 'comment': '', + }, + # (131, 42, 'NS', '', 'ns2.hostserv.eu', 10800, None, None), + { + 'id': 131, + 'type': 'NS', + 'ownername': '', + 'targetname': 'ns2.hostserv.eu', + 'ttl': 10800, + 'comment': '', + }, + # (132, 42, 'NS', '', 'ns1.hostserv.eu', 10800, None, None), + { + 'id': 132, + 'type': 'NS', + 'ownername': '', + 'targetname': 'ns1.hostserv.eu', + 'ttl': 10800, + 'comment': '', + }, +] + + +def validate_wsdl_call(conditions): + def predicate(content): + assert content.startswith(b"<?xml version='1.0' encoding='utf-8'?>\n") + + root = lxml.etree.fromstring(content) + header = None + body = None + + for header_ in root.iter(lxml.etree.QName('http://schemas.xmlsoap.org/soap/envelope/', 'Header').text): + header = header_ + for body_ in root.iter(lxml.etree.QName('http://schemas.xmlsoap.org/soap/envelope/', 'Body').text): + body = body_ + + for condition in conditions: + if not condition(content, header, body): + return False + return True + + return predicate + + +def get_wsdl_value(root, name): + for auth in root.iter(name): + return auth + raise Exception('Cannot find child "{0}" in node {1}: {2}'.format(name, root, lxml.etree.tostring(root))) + + +def expect_wsdl_authentication(username, password): + def predicate(content, header, body): + auth = get_wsdl_value(header, lxml.etree.QName('auth', 'authenticate').text) + assert get_wsdl_value(auth, 'UserName').text == username + assert get_wsdl_value(auth, 'Password').text == password + return True + + return predicate + + +def check_wsdl_nil(node): + nil_flag = node.get(lxml.etree.QName('http://www.w3.org/2001/XMLSchema-instance', 'nil')) + if nil_flag != 'true': + print(nil_flag) + assert nil_flag == 'true' + + +def check_wsdl_value(node, value, type=None): + if type is not None: + type_text = node.get(lxml.etree.QName('http://www.w3.org/2001/XMLSchema-instance', 'type')) + assert type_text is not None, 'Cannot find type in {0}: {1}'.format(node, lxml.etree.tostring(node)) + i = type_text.find(':') + if i < 0: + ns = None + else: + ns = node.nsmap.get(type_text[:i]) + type_text = type_text[i + 1:] + if ns != type[0] or type_text != type[1]: + print(ns, type[0], type_text, type[1]) + assert ns == type[0] and type_text == type[1] + if node.text != value: + print(node.text, value) + assert node.text == value + + +def find_xml_map_entry(map_root, key_name, allow_non_existing=False): + for map_entry in map_root.iter('item'): + key = get_wsdl_value(map_entry, 'key') + value = get_wsdl_value(map_entry, 'value') + if key.text == key_name: + check_wsdl_value(key, key_name, type=('http://www.w3.org/2001/XMLSchema', 'string')) + return value + if allow_non_existing: + return None + raise Exception('Cannot find map entry with key "{0}" in node {1}: {2}'.format(key_name, map_root, lxml.etree.tostring(map_root))) + + +def expect_wsdl_value(path, value, type=None): + def predicate(content, header, body): + node = body + for entry in path: + node = get_wsdl_value(node, entry) + check_wsdl_value(node, value, type=type) + return True + + return predicate + + +def add_wsdl_answer_start_lines(lines): + lines.extend([ + '<?xml version="1.0" encoding="UTF-8"?>\n', + '<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"' + ' xmlns:ns1="https://ns1.hosttech.eu/public/api"' + ' xmlns:xsd="http://www.w3.org/2001/XMLSchema"' + ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"' + ' xmlns:ns2="http://xml.apache.org/xml-soap"' + ' xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/"' + ' SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">', + '<SOAP-ENV:Header>', + '<ns1:authenticateResponse>', + '<return xsi:type="xsd:boolean">true</return>', + '</ns1:authenticateResponse>', + '</SOAP-ENV:Header>', + '<SOAP-ENV:Body>', + ]) + + +def add_wsdl_answer_end_lines(lines): + lines.extend([ + '</SOAP-ENV:Body>', + '</SOAP-ENV:Envelope>' + ]) + + +def add_wsdl_dns_record_lines(lines, entry, tag_name): + lines.extend([ + '<{tag_name} xsi:type="ns2:Map">'.format(tag_name=tag_name), + '<item><key xsi:type="xsd:string">id</key><value xsi:type="xsd:int">{value}</value></item>'.format(value=entry[0]), + '<item><key xsi:type="xsd:string">zone</key><value xsi:type="xsd:int">{value}</value></item>'.format(value=entry[1]), + '<item><key xsi:type="xsd:string">type</key><value xsi:type="xsd:string">{value}</value></item>'.format(value=entry[2]), + '<item><key xsi:type="xsd:string">prefix</key><value xsi:type="xsd:string">{value}</value></item>'.format(value=entry[3]), + '<item><key xsi:type="xsd:string">target</key><value xsi:type="xsd:string">{value}</value></item>'.format(value=entry[4]), + '<item><key xsi:type="xsd:string">ttl</key><value xsi:type="xsd:int">{value}</value></item>'.format(value=entry[5]), + ]) + if entry[6] is None: + lines.append('<item><key xsi:type="xsd:string">comment</key><value xsi:nil="true"/></item>') + else: + lines.append('<item><key xsi:type="xsd:string">comment</key><value xsi:type="xsd:string">{value}</value></item>'.format(value=entry[6])) + if entry[7] is None: + lines.append('<item><key xsi:type="xsd:string">priority</key><value xsi:nil="true"/></item>') + else: + lines.append('<item><key xsi:type="xsd:string">priority</key><value xsi:type="xsd:int">{value}</value></item>'.format(value=entry[7])) + lines.append('</{tag_name}>'.format(tag_name=tag_name)) + + +def create_wsdl_zones_answer(zone_id, zone_name, entries): + lines = [] + add_wsdl_answer_start_lines(lines) + lines.extend([ + '<ns1:getZoneResponse>', + '<return xsi:type="ns2:Map">', + '<item><key xsi:type="xsd:string">id</key><value xsi:type="xsd:int">{zone_id}</value></item>'.format(zone_id=zone_id), + '<item><key xsi:type="xsd:string">user</key><value xsi:type="xsd:int">23</value></item>', + '<item><key xsi:type="xsd:string">name</key><value xsi:type="xsd:string">{zone_name}</value></item>'.format(zone_name=zone_name), + '<item><key xsi:type="xsd:string">email</key><value xsi:type="xsd:string">dns@hosttech.eu</value></item>', + '<item><key xsi:type="xsd:string">ttl</key><value xsi:type="xsd:int">10800</value></item>', + '<item><key xsi:type="xsd:string">nameserver</key><value xsi:type="xsd:string">ns1.hostserv.eu</value></item>', + '<item><key xsi:type="xsd:string">serial</key><value xsi:type="xsd:string">12345</value></item>', + '<item><key xsi:type="xsd:string">serialLastUpdate</key><value xsi:type="xsd:int">0</value></item>', + '<item><key xsi:type="xsd:string">refresh</key><value xsi:type="xsd:int">7200</value></item>', + '<item><key xsi:type="xsd:string">retry</key><value xsi:type="xsd:int">120</value></item>', + '<item><key xsi:type="xsd:string">expire</key><value xsi:type="xsd:int">1234567</value></item>', + '<item><key xsi:type="xsd:string">template</key><value xsi:nil="true"/></item>', + '<item><key xsi:type="xsd:string">ns3</key><value xsi:type="xsd:int">1</value></item>', + ]) + lines.append( + '<item><key xsi:type="xsd:string">records</key><value SOAP-ENC:arrayType="ns2:Map[{count}]" xsi:type="SOAP-ENC:Array">'.format( + count=len(entries))) + for entry in entries: + add_wsdl_dns_record_lines(lines, entry, 'item') + lines.extend([ + '</value>', + '</item>', + '</return>', + '</ns1:getZoneResponse>', + ]) + add_wsdl_answer_end_lines(lines) + return ''.join(lines) + + +def create_wsdl_zone_not_found_answer(): + lines = [ + '<?xml version="1.0" encoding="UTF-8"?>\n', + '<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"' + ' xmlns:ns1="https://ns1.hosttech.eu/public/api"' + ' xmlns:xsd="http://www.w3.org/2001/XMLSchema"' + ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"' + ' xmlns:ns2="http://xml.apache.org/xml-soap"' + ' xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/"' + ' SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">', + '<SOAP-ENV:Header>', + '<ns1:authenticateResponse>', + '<return xsi:type="xsd:boolean">true</return>', + '</ns1:authenticateResponse>', + '</SOAP-ENV:Header>', + '<SOAP-ENV:Fault>', + '<faultstring>zone not found</faultstring>' + '</SOAP-ENV:Fault>', + '</SOAP-ENV:Envelope>' + ] + return ''.join(lines) + + +def check_wsdl_record(record_data, entry): + check_wsdl_value(find_xml_map_entry(record_data, 'type'), entry[2], type=('http://www.w3.org/2001/XMLSchema', 'string')) + prefix = find_xml_map_entry(record_data, 'prefix') + if entry[3]: + check_wsdl_value(prefix, entry[3], type=('http://www.w3.org/2001/XMLSchema', 'string')) + elif prefix is not None: + check_wsdl_nil(prefix) + check_wsdl_value(find_xml_map_entry(record_data, 'target'), entry[4], type=('http://www.w3.org/2001/XMLSchema', 'string')) + check_wsdl_value(find_xml_map_entry(record_data, 'ttl'), str(entry[5]), type=('http://www.w3.org/2001/XMLSchema', 'int')) + if entry[6] is None: + comment = find_xml_map_entry(record_data, 'comment', allow_non_existing=True) + if comment is not None: + check_wsdl_nil(comment) + else: + check_wsdl_value(find_xml_map_entry(record_data, 'comment'), entry[6], type=('http://www.w3.org/2001/XMLSchema', 'string')) + if entry[7] is None: + check_wsdl_nil(find_xml_map_entry(record_data, 'priority')) + else: + check_wsdl_value(find_xml_map_entry(record_data, 'priority'), entry[7], type=('http://www.w3.org/2001/XMLSchema', 'string')) + + +def validate_wsdl_add_request(zone, entry): + def predicate(content, header, body): + fn_data = get_wsdl_value(body, lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'addRecord').text) + check_wsdl_value(get_wsdl_value(fn_data, 'search'), zone, type=('http://www.w3.org/2001/XMLSchema', 'string')) + check_wsdl_record(get_wsdl_value(fn_data, 'recorddata'), entry) + return True + + return predicate + + +def validate_wsdl_update_request(entry): + def predicate(content, header, body): + fn_data = get_wsdl_value(body, lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'updateRecord').text) + check_wsdl_value(get_wsdl_value(fn_data, 'recordId'), str(entry[0]), type=('http://www.w3.org/2001/XMLSchema', 'int')) + check_wsdl_record(get_wsdl_value(fn_data, 'recorddata'), entry) + return True + + return predicate + + +def validate_wsdl_del_request(entry): + def predicate(content, header, body): + fn_data = get_wsdl_value(body, lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'deleteRecord').text) + check_wsdl_value(get_wsdl_value(fn_data, 'recordId'), str(entry[0]), type=('http://www.w3.org/2001/XMLSchema', 'int')) + return True + + return predicate + + +def create_wsdl_add_result(entry): + lines = [] + add_wsdl_answer_start_lines(lines) + lines.append('<ns1:addRecordResponse>') + add_wsdl_dns_record_lines(lines, entry, 'return') + lines.append('</ns1:addRecordResponse>') + add_wsdl_answer_end_lines(lines) + return ''.join(lines) + + +def create_wsdl_update_result(entry): + lines = [] + add_wsdl_answer_start_lines(lines) + lines.append('<ns1:updateRecordResponse>') + add_wsdl_dns_record_lines(lines, entry, 'return') + lines.append('</ns1:updateRecordResponse>') + add_wsdl_answer_end_lines(lines) + return ''.join(lines) + + +def create_wsdl_del_result(success): + lines = [] + add_wsdl_answer_start_lines(lines) + lines.extend([ + '<ns1:deleteRecordResponse>', + '<return xsi:type="xsd:boolean">{success}</return>'.format(success='true' if success else 'false'), + '</ns1:deleteRecordResponse>', + ]) + add_wsdl_answer_end_lines(lines) + return ''.join(lines) + + +HOSTTECH_WSDL_DEFAULT_ZONE_RESULT = create_wsdl_zones_answer(42, 'example.com', HOSTTECH_WSDL_DEFAULT_ENTRIES) + +HOSTTECH_WSDL_ZONE_NOT_FOUND = create_wsdl_zone_not_found_answer() + +HOSTTECH_JSON_ZONE_LIST_RESULT = { + "data": [ + { + "id": 42, + "name": "example.com", + "email": "test@example.com", + "ttl": 10800, + "nameserver": "ns1.hosttech.ch", + "dnssec": False, + }, + { + "id": 43, + "name": "foo.com", + "email": "test@foo.com", + "ttl": 10800, + "nameserver": "ns1.hosttech.ch", + 'dnssec': True, + 'dnssec_email': 'test@foo.com', + }, + ], +} + +HOSTTECH_JSON_ZONE_GET_RESULT = { + "data": { + "id": 42, + "name": "example.com", + "email": "test@example.com", + "ttl": 10800, + "nameserver": "ns1.hosttech.ch", + "dnssec": False, + "records": HOSTTECH_JSON_DEFAULT_ENTRIES, + } +} + +HOSTTECH_JSON_ZONE_2_GET_RESULT = { + "data": { + "id": 43, + "name": "foo.com", + "email": "test@foo.com", + "ttl": 10800, + "nameserver": "ns1.hosttech.ch", + 'dnssec': True, + 'dnssec_email': 'test@foo.com', + 'ds_records': [ + { + 'key_tag': 12345, + 'algorithm': 8, + 'digest_type': 1, + 'digest': '012356789ABCDEF0123456789ABCDEF012345678', + 'flags': 257, + 'protocol': 3, + 'public_key': + 'MuhdzsQdqEGShwjtJDKZZjdKqUSGluFzTTinpuEeIRzLLcgkwgAPKWFa ' + 'eQntNlmcNDeCziGwpdvhJnvKXEMbFcZwsaDIJuWqERxAQNGABWfPlCLh ' + 'HQPnbpRPNKipSdBaUhuOubvFvjBpFAwiwSAapRDVsAgKvjXucfXpFfYb ' + 'pCundbAXBWhbpHVbqgmGoixXzFSwUsGVYLPpBCiDlLJwzjRKYYaoVYge ' + 'kMtKFYUVnWIKbectWkDFdVqXwkKigCUDiuTTJxOBRJRNzGiDNMWBjYSm ' + 'bBCAHMaMYaghLbYTwyKXltdHTHwBwtswGNfpnEdSpKFzZJonBZArQfHD ' + 'lfceKgmKwEF=', + }, + { + 'key_tag': 12345, + 'algorithm': 8, + 'digest_type': 2, + 'digest': '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF', + 'flags': 257, + 'protocol': 3, + 'public_key': + 'MuhdzsQdqEGShwjtJDKZZjdKqUSGluFzTTinpuEeIRzLLcgkwgAPKWFa ' + 'eQntNlmcNDeCziGwpdvhJnvKXEMbFcZwsaDIJuWqERxAQNGABWfPlCLh ' + 'HQPnbpRPNKipSdBaUhuOubvFvjBpFAwiwSAapRDVsAgKvjXucfXpFfYb ' + 'pCundbAXBWhbpHVbqgmGoixXzFSwUsGVYLPpBCiDlLJwzjRKYYaoVYge ' + 'kMtKFYUVnWIKbectWkDFdVqXwkKigCUDiuTTJxOBRJRNzGiDNMWBjYSm ' + 'bBCAHMaMYaghLbYTwyKXltdHTHwBwtswGNfpnEdSpKFzZJonBZArQfHD ' + 'lfceKgmKwEF=', + } + ], + "records": [], + } +} + +HOSTTECH_JSON_ZONE_RECORDS_GET_RESULT = { + "data": HOSTTECH_JSON_DEFAULT_ENTRIES, +} diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record.py new file mode 100644 index 000000000..f5bb05c7e --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record.py @@ -0,0 +1,835 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# 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 + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hetzner_dns_record + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hetzner import ( + HETZNER_JSON_DEFAULT_ENTRIES, + HETZNER_JSON_ZONE_GET_RESULT, + HETZNER_JSON_ZONE_LIST_RESULT, + HETZNER_JSON_ZONE_RECORDS_GET_RESULT, +) + + +class TestHetznerDNSRecordJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hetzner_dns_record.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_id': '23', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json({'error': {'message': 'zone not found', 'code': 404}}), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id_prefix(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_id': '23', + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '23') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json({'records': [], 'error': {'message': 'zone not found', 'code': 404}}), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_json({'message': 'Invalid authentication credentials'}), + ]) + + assert result['msg'] == ( + 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401): Invalid authentication credentials' + ) + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://dns.hetzner.com/api/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_conversion_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'TXT', + 'ttl': 3600, + 'value': u'"hellö', + 'txt_transformation': 'quoted', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['msg'] == ( + 'Error while converting DNS values: While processing record from the user: Missing double quotation mark at the end of value' + ) + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['diff']['before'] == result['diff']['after'] + + def test_idempotency_absent_value(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.6', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['diff']['before'] == {} + assert result['diff']['before'] == {} + + def test_idempotency_absent_value_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'prefix': '*', + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.6', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + + def test_idempotency_absent_type(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + + def test_idempotency_absent_record(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.6', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + + def test_absent_check(self, mocker): + record = HETZNER_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': ((record['name'] + '.') if record['name'] != '@' else '') + 'example.com', + 'type': record['type'], + 'value': record['value'], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_absent(self, mocker): + record = HETZNER_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': ((record['name'] + '.') if record['name'] != '@' else '') + 'example.com', + 'type': record['type'], + 'value': record['value'], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/{0}'.format(record['id'])) + .result_str(''), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_id': '42', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_check_mode_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_id': '42', + 'prefix': '@', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_diff': True, + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == {} + assert result['diff']['after'] == { + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + 'extra': {}, + } + + def test_change_add_one(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '128 issue "letsencrypt.org xxx"', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], '128 issue "letsencrypt.org xxx"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': '@', + 'value': '128 issue "letsencrypt.org xxx"', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == {} + assert result['diff']['after'] == { + 'prefix': '', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '128 issue "letsencrypt.org xxx"', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + + def test_change_add_one_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': '128 issue "letsencrypt.org"', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], '128 issue "letsencrypt.org"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': '@', + 'value': '128 issue "letsencrypt.org"', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_idn_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '☺', + 'type': 'CAA', + 'ttl': 3600, + 'value': '128 issue "letsencrypt.org"', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], 'xn--74h') + .expect_json_value(['value'], '128 issue "letsencrypt.org"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': 'xn--74h', + 'value': '128 issue "letsencrypt.org"', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_modify_check(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 300, + 'value': '1.2.3.5', + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_modify(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 300, + 'value': '1.2.3.5', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/126') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'A') + .expect_json_value(['ttl'], 300) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '*') + .expect_json_value(['value'], '1.2.3.5') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '126', + 'type': 'A', + 'name': '*', + 'value': '1.2.3.5', + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_create_bad(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 300, + 'value': '1.2.3.5.6', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 422) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'A') + .expect_json_value(['ttl'], 300) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '*') + .expect_json_value(['value'], '1.2.3.5.6') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '', + 'type': '', + 'name': '', + 'value': '', + 'zone_id': '', + 'created': '', + 'modified': '', + }, + 'error': { + 'message': 'invalid A record', + 'code': 422, + } + }), + ]) + + assert result['msg'] == ( + 'Error: The new A record with value "1.2.3.5.6" and TTL 300 has not been accepted' + ' by the server with error message "invalid A record" (error code 422)' + ) diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_info.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_info.py new file mode 100644 index 000000000..8f0a79b02 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_info.py @@ -0,0 +1,798 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# 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 + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import patch + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hetzner_dns_record_info + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hetzner import ( + HETZNER_JSON_ZONE_GET_RESULT, + HETZNER_JSON_ZONE_LIST_RESULT, + HETZNER_JSON_ZONE_RECORDS_GET_RESULT, +) + + +def mock_sleep(delay): + pass + + +class TestHetznerDNSRecordInfoJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hetzner_dns_record_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message='')), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .result_json(dict(message='')), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://dns.hetzner.com/api/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_too_many_retries(self, mocker): + sleep_values = [5, 10, 1, 1, 1, 60, 10, 1, 10, 3.1415] + + def sleep_check(delay): + expected = sleep_values.pop(0) + assert delay == expected + + with patch('time.sleep', sleep_check): + result = self.run_module_failed(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '1') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '0') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '-1') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '61') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', 'foo') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '0.9') + .result_str(''), + FetchUrlCall('GET', 429) + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '3.1415') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '42') + .result_str(''), + ]) + print(sleep_values) + assert result['msg'] == 'Error: Stopping after 10 failed retries with 429 Too Many Attempts' + assert len(sleep_values) == 0 + + def test_conversion_error(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_failed(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'TXT', + 'txt_transformation': 'quoted', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json({'records': [ + { + 'id': '201', + 'type': 'TXT', + 'name': '@', + 'value': u'"hellö', + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + ]}), + ]) + + assert result['msg'] == ( + 'Error while converting DNS values: While processing record from API: Missing double quotation mark at the end of value' + ) + + def test_get_single(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert len(result['records']) == 1 + assert result['records'][0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.4', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + + def test_get_single_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prefix': '*', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert len(result['records']) == 1 + assert result['records'][0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.5', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + + def test_get_all_for_one_record(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'what': 'all_types_for_record', + 'zone_name': 'example.com', + 'record': '*.example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert len(result['records']) == 2 + assert result['records'][0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.5', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::4', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + + def test_get_all_for_one_record_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'what': 'all_types_for_record', + 'zone_name': 'example.com.', + 'prefix': '@', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert len(result['records']) == 7 + assert result['records'][0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.4', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][1] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::3', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': '10 example.com', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][3] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': 'helium.ns.hetzner.de.', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': 'hydrogen.ns.hetzner.com.', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][5] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': 'oxygen.ns.hetzner.com.', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][6] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': 'hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + + def test_get_all(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foobar', + 'what': 'all_records', + 'zone_id': '42', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foobar') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foobar') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert len(result['records']) == 10 + assert result['records'][0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.4', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.5', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::3', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][3] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::4', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': '10 example.com', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][5] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': 'helium.ns.hetzner.de.', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][6] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': 'hydrogen.ns.hetzner.com.', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][7] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': 'oxygen.ns.hetzner.com.', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][8] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': 'hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][9] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': u'bär "with quotes" (use \\ to escape)', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + + def test_get_single_txt_api(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'txt_transformation': 'api', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['records'] == [{ + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': u'bär " \\"with quotes\\"" " " "(use \\\\ to escape)"', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + }] + + def test_get_single_txt_quoted(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'txt_transformation': 'quoted', + 'txt_character_encoding': 'decimal', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['records'] == [{ + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': u'"b\\195\\164r \\"with quotes\\" (use \\\\ to escape)"', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + }] + + def test_get_single_txt_quoted_octal(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'txt_transformation': 'quoted', + 'txt_character_encoding': 'octal', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['records'] == [{ + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': u'"b\\303\\244r \\"with quotes\\" (use \\\\ to escape)"', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + }] diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_set.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_set.py new file mode 100644 index 000000000..4bf2d1050 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_set.py @@ -0,0 +1,1901 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# 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 + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hetzner_dns_record_set + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hetzner import ( + HETZNER_JSON_DEFAULT_ENTRIES, + HETZNER_JSON_ZONE_GET_RESULT, + HETZNER_JSON_ZONE_LIST_RESULT, + HETZNER_JSON_ZONE_RECORDS_GET_RESULT, +) + + +class TestHetznerDNSRecordJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hetzner_dns_record_set.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_id': '23', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json({'error': {'message': 'zone not found', 'code': 404}}), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id_prefix(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_id': '23', + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '23') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json({'records': [], 'error': {'message': 'zone not found', 'code': 404}}), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_json({'message': 'Invalid authentication credentials'}), + ]) + + assert result['msg'] == ( + 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401): Invalid authentication credentials' + ) + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://dns.hetzner.com/api/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_conversion_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'TXT', + 'ttl': 3600, + 'value': [ + u'"hellö', + ], + 'txt_transformation': 'quoted', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['msg'] == ( + 'Error while converting DNS values: While processing record from the user: Missing double quotation mark at the end of value' + ) + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': ['10 example.com'], + } + assert result['diff']['before'] == result['diff']['after'] + + def test_idempotency_absent_value(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['diff']['before'] == { + 'record': '*.example.com', + 'prefix': '*', + 'type': 'A', + 'ttl': 3600, + 'value': ['1.2.3.5'], + } + assert result['diff']['before'] == result['diff']['after'] + + def test_idempotency_absent_value_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'prefix': '*', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + + def test_idempotency_absent_ttl(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 1800, + 'value': [ + '1.2.3.5', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + + def test_idempotency_absent_type(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + + def test_idempotency_absent_record(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'warnings' not in result + + def test_idempotency_absent_record_warn(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep_and_warn', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + assert list(result['warnings']) == ["Record already exists with different value. Set on_existing=replace to remove it"] + + def test_idempotency_absent_record_fail(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep_and_fail', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['msg'] == "Record already exists with different value. Set on_existing=replace to remove it" + + def test_absent(self, mocker): + record = HETZNER_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': ((record['name'] + '.') if record['name'] != '@' else '') + 'example.com', + 'type': record['type'], + 'ttl': record['ttl'], + 'value': [ + record['value'], + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/{0}'.format(record['id'])) + .result_str(''), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_absent_error(self, mocker): + record = HETZNER_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': ((record['name'] + '.') if record['name'] != '@' else '') + 'example.com', + 'type': record['type'], + 'ttl': record['ttl'], + 'value': [ + record['value'], + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/{0}'.format(record['id'])) + .return_header('Content-Type', 'application/json') + .result_json({'error': {'message': 'Internal Server Error', 'code': 500}}), + ]) + + print(result['msg']) + assert result['msg'] == ( + 'Error: Expected HTTP status 200, 404 for DELETE https://dns.hetzner.com/api/v1/records/125,' + ' but got HTTP status 500 (Internal Server Error) with error message "Internal Server Error" (error code 500)' + ) + + def test_absent_bulk(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'value': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/130') + .result_str(''), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/131') + .result_str(''), + # Record 132 has been deleted between querying and we trying to delete it + FetchUrlCall('DELETE', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/132') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'record does not exist'}), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_absent_bulk_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'value': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/130') + .result_str(''), + FetchUrlCall('DELETE', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/131') + .return_header('Content-Type', 'application/json') + .result_json({'error': {'message': 'Internal Server Error', 'code': 500}}), + ]) + + assert result['msg'] == ( + 'Error: Expected HTTP status 200, 404 for DELETE https://dns.hetzner.com/api/v1/records/131,' + ' but got HTTP status 500 (Internal Server Error) with error message "Internal Server Error" (error code 500)' + ) + + def test_absent_other_value(self, mocker): + record = HETZNER_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': ((record['name'] + '.') if record['name'] != '@' else '') + 'example.com', + 'type': record['type'], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/{0}'.format(record['id'])) + .result_str(''), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_id': '42', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_check_mode_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_id': '42', + 'prefix': '@', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + '_ansible_diff': True, + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == {} + assert result['diff']['after'] == { + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': ['0 issue "letsencrypt.org"'], + } + + def test_change_add_one(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org xxx"', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], '128 issue "letsencrypt.org xxx"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': '@', + 'value': '128 issue "letsencrypt.org xxx"', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], '128 issue "letsencrypt.org"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': '@', + 'value': '128 issue "letsencrypt.org"', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_idn_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '☺', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], 'xn--74h') + .expect_json_value(['value'], '128 issue "letsencrypt.org"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': 'xn--74h', + 'value': '128 issue "letsencrypt.org"', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_modify_list_fail(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': None, + 'value': [ + 'helium.ns.hetzner.de.', + 'ytterbium.ns.hetzner.com.', + ], + 'on_existing': 'keep_and_fail', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['msg'] == "Record already exists with different value. Set on_existing=replace to replace it" + + def test_change_modify_list_warn(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'helium.ns.hetzner.de.', + 'ytterbium.ns.hetzner.com.', + ], + 'on_existing': 'keep_and_warn', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': None, + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + } + assert result['diff']['after'] == result['diff']['before'] + assert list(result['warnings']) == ["Record already exists with different value. Set on_existing=replace to replace it"] + + def test_change_modify_list_keep(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': None, + 'value': [ + 'helium.ns.hetzner.de.', + 'ytterbium.ns.hetzner.com.', + ], + 'on_existing': 'keep', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert 'warnings' not in result + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': None, + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + } + assert result['diff']['after'] == result['diff']['before'] + + def test_change_modify_list(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': None, + 'value': [ + 'helium.ns.hetzner.de.', + 'ytterbium.ns.hetzner.com.', + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/131') + .result_str(''), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/132') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value_absent(['ttl']) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'ytterbium.ns.hetzner.com.') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '132', + 'type': 'NS', + 'name': '@', + 'value': 'ytterbium.ns.hetzner.com.', + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': None, + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + } + assert result['diff']['after'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': None, + 'value': ['helium.ns.hetzner.de.', 'ytterbium.ns.hetzner.com.'], + } + + def test_change_modify_txt_unquoted(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'foo.example.com', + 'type': 'TXT', + 'ttl': None, + 'value': [u'bär "with quotes" (use \\ to escape)!'], + 'txt_transformation': 'unquoted', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/201') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'TXT') + .expect_json_value_absent(['ttl']) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], 'foo') + .expect_json_value(['value'], u'"bär \\"with quotes\\" (use \\\\ to escape)!"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '201', + 'type': 'TXT', + 'name': 'foo', + 'value': u'"bär \\"with quotes\\" (use \\\\ to escape)!"', + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': [u'bär "with quotes" (use \\ to escape)'], + } + assert result['diff']['after'] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': [u'bär "with quotes" (use \\ to escape)!'], + } + + def test_change_modify_txt_quoted(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'foo.example.com', + 'type': 'TXT', + 'ttl': None, + 'value': [r'"b\195\164r \"with quotes\" (use \\ to escape)!"'], + 'txt_transformation': 'quoted', + 'txt_character_encoding': 'decimal', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/201') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'TXT') + .expect_json_value_absent(['ttl']) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], 'foo') + .expect_json_value(['value'], u'"bär \\"with quotes\\" (use \\\\ to escape)!"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '201', + 'type': 'TXT', + 'name': 'foo', + 'value': u'"bär \\"with quotes\\" (use \\\\ to escape)!"', + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': [r'"b\195\164r \"with quotes\" (use \\ to escape)"'], + } + assert result['diff']['after'] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': [r'"b\195\164r \"with quotes\" (use \\ to escape)!"'], + } + + def test_change_modify_txt_quoted_octal(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'foo.example.com', + 'type': 'TXT', + 'ttl': None, + 'value': [r'"b\303\244r \"with quotes\" (use \\ to escape)!"'], + 'txt_transformation': 'quoted', + 'txt_character_encoding': 'octal', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/201') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'TXT') + .expect_json_value_absent(['ttl']) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], 'foo') + .expect_json_value(['value'], u'"bär \\"with quotes\\" (use \\\\ to escape)!"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '201', + 'type': 'TXT', + 'name': 'foo', + 'value': u'"bär \\"with quotes\\" (use \\\\ to escape)!"', + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': [r'"b\303\244r \"with quotes\" (use \\ to escape)"'], + } + assert result['diff']['after'] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': [r'"b\303\244r \"with quotes\" (use \\ to escape)!"'], + } + + def test_change_modify_txt_api(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'foo.example.com', + 'type': 'TXT', + 'ttl': None, + 'value': [u'bär " " \\"with " " quotes\\" " (use \\\\ to escape)!"'], + 'txt_transformation': 'api', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/201') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'TXT') + .expect_json_value_absent(['ttl']) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], 'foo') + .expect_json_value(['value'], u'bär " " \\"with " " quotes\\" " (use \\\\ to escape)!"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '201', + 'type': 'TXT', + 'name': 'foo', + 'value': u'bär " " \\"with " " quotes\\" " (use \\\\ to escape)!"', + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': [u'bär " \\"with quotes\\"" " " "(use \\\\ to escape)"'], + } + assert result['diff']['after'] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': [u'bär " " \\"with " " quotes\\" " (use \\\\ to escape)!"'], + } + + def test_change_modify_bulk(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'a1', + 'a2', + 'a3', + 'a4', + 'a5', + 'a6', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/132') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'a1') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '132', + 'type': 'NS', + 'name': '@', + 'value': 'a1', + 'ttl': 10800, + 'zone_id': '42', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/131') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'a2') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '131', + 'type': 'NS', + 'name': '@', + 'value': 'a2', + 'ttl': 10800, + 'zone_id': '42', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/130') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'a3') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '130', + 'type': 'NS', + 'name': '@', + 'value': 'a3', + 'ttl': 10800, + 'zone_id': '42', + }, + }), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/bulk') + .expect_json_value_absent(['records', 0, 'id']) + .expect_json_value(['records', 0, 'type'], 'NS') + .expect_json_value(['records', 0, 'ttl'], 10800) + .expect_json_value(['records', 0, 'zone_id'], '42') + .expect_json_value(['records', 0, 'name'], '@') + .expect_json_value(['records', 0, 'value'], 'a4') + .expect_json_value_absent(['records', 1, 'id']) + .expect_json_value(['records', 1, 'type'], 'NS') + .expect_json_value(['records', 1, 'ttl'], 10800) + .expect_json_value(['records', 1, 'zone_id'], '42') + .expect_json_value(['records', 1, 'name'], '@') + .expect_json_value(['records', 1, 'value'], 'a5') + .expect_json_value_absent(['records', 2, 'id']) + .expect_json_value(['records', 2, 'type'], 'NS') + .expect_json_value(['records', 2, 'ttl'], 10800) + .expect_json_value(['records', 2, 'zone_id'], '42') + .expect_json_value(['records', 2, 'name'], '@') + .expect_json_value(['records', 2, 'value'], 'a6') + .expect_json_value_absent(['records', 3]) + .return_header('Content-Type', 'application/json') + .result_json({ + 'invalid_records': [], + 'valid_records': [], + 'records': [ + { + 'id': '300', + 'type': 'NS', + 'name': '@', + 'value': 'a4', + 'ttl': 10800, + 'zone_id': '42', + }, + { + 'id': '301', + 'type': 'NS', + 'name': '@', + 'value': 'a5', + 'ttl': 10800, + 'zone_id': '42', + }, + { + 'id': '302', + 'type': 'NS', + 'name': '@', + 'value': 'a6', + 'ttl': 10800, + 'zone_id': '42', + }, + ], + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' not in result + + def test_change_modify_bulk_errors(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'a1', + 'a2', + 'a3', + 'a4', + 'a5', + 'a6', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/132') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'a1') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'Internal Server Error'}), + ]) + + assert result['msg'] == ( + 'Error: Expected HTTP status 200, 422 for PUT https://dns.hetzner.com/api/v1/records/132,' + ' but got HTTP status 500 (Internal Server Error) with message "Internal Server Error"' + ) + + def test_change_modify_bulk_errors_2(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'a1', + 'a2', + 'a3', + 'a4', + 'a5', + 'a6', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/132') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'a1') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '132', + 'type': 'NS', + 'name': '@', + 'value': 'a1', + 'ttl': 10800, + 'zone_id': '42', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/131') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'a2') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '131', + 'type': 'NS', + 'name': '@', + 'value': 'a2', + 'ttl': 10800, + 'zone_id': '42', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/130') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'a3') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '130', + 'type': 'NS', + 'name': '@', + 'value': 'a3', + 'ttl': 10800, + 'zone_id': '42', + }, + }), + FetchUrlCall('POST', 422) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/bulk') + .expect_json_value_absent(['records', 0, 'id']) + .expect_json_value(['records', 0, 'type'], 'NS') + .expect_json_value(['records', 0, 'ttl'], 10800) + .expect_json_value(['records', 0, 'zone_id'], '42') + .expect_json_value(['records', 0, 'name'], '@') + .expect_json_value(['records', 0, 'value'], 'a4') + .expect_json_value_absent(['records', 1, 'id']) + .expect_json_value(['records', 1, 'type'], 'NS') + .expect_json_value(['records', 1, 'ttl'], 10800) + .expect_json_value(['records', 1, 'zone_id'], '42') + .expect_json_value(['records', 1, 'name'], '@') + .expect_json_value(['records', 1, 'value'], 'a5') + .expect_json_value_absent(['records', 2, 'id']) + .expect_json_value(['records', 2, 'type'], 'NS') + .expect_json_value(['records', 2, 'ttl'], 10800) + .expect_json_value(['records', 2, 'zone_id'], '42') + .expect_json_value(['records', 2, 'name'], '@') + .expect_json_value(['records', 2, 'value'], 'a6') + .expect_json_value_absent(['records', 3]) + .return_header('Content-Type', 'application/json') + .result_json({ + 'invalid_records': [ + { + 'type': 'NS', + 'name': '@', + 'value': 'a4', + 'ttl': 10800, + 'zone_id': '42', + }, + { + 'type': 'NS', + 'name': '@', + 'value': 'a5', + 'ttl': 10800, + 'zone_id': '42', + }, + ], + 'valid_records': [ + { + 'type': 'NS', + 'name': '@', + 'value': 'a6', + 'ttl': 10800, + 'zone_id': '42', + }, + ], + 'records': [], + 'error': { + 'message': 'invalid NS record, invalid NS record, ', + 'code': 422, + }, + }), + ]) + + assert result['msg'] == ( + 'Errors: Creating NS record "a4" with TTL 10800 for zone 42 failed with unknown reason;' + ' Creating NS record "a5" with TTL 10800 for zone 42 failed with unknown reason' + ) + + def test_change_change_bad(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.4.5', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 422) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/125') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'A') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], '1.2.3.4.5') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '', + 'type': '', + 'name': '', + 'value': '', + 'zone_id': '', + 'created': '', + 'modified': '', + }, + 'error': { + 'message': 'invalid A record', + 'code': 422, + } + }), + ]) + + assert result['msg'] == ( + 'Error: The updated A record with value "1.2.3.4.5" and TTL 3600 has not been accepted' + ' by the server with error message "invalid A record" (error code 422)' + ) diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_set_info.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_set_info.py new file mode 100644 index 000000000..a42add09d --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_set_info.py @@ -0,0 +1,696 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# 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 + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import patch + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hetzner_dns_record_set_info + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hetzner import ( + HETZNER_JSON_ZONE_GET_RESULT, + HETZNER_JSON_ZONE_LIST_RESULT, + HETZNER_JSON_ZONE_RECORDS_GET_RESULT, +) + + +def mock_sleep(delay): + pass + + +class TestHetznerDNSRecordSetInfoJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hetzner_dns_record_set_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://dns.hetzner.com/api/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_too_many_retries(self, mocker): + sleep_values = [5, 10, 1, 1, 1, 60, 10, 1, 10, 3.1415] + + def sleep_check(delay): + expected = sleep_values.pop(0) + assert delay == expected + + with patch('time.sleep', sleep_check): + result = self.run_module_failed(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '1') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '0') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '-1') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '61') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', 'foo') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '0.9') + .result_str(''), + FetchUrlCall('GET', 429) + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '3.1415') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '42') + .result_str(''), + ]) + print(sleep_values) + assert result['msg'] == 'Error: Stopping after 10 failed retries with 429 Too Many Attempts' + assert len(sleep_values) == 0 + + def test_conversion_error(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_failed(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'TXT', + 'txt_transformation': 'quoted', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json({'records': [ + { + 'id': '201', + 'type': 'TXT', + 'name': '@', + 'value': u'"hellö', + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + ]}), + ]) + + assert result['msg'] == ( + 'Error while converting DNS values: While processing record from API: Missing double quotation mark at the end of value' + ) + + def test_get_single(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'set' in result + assert result['set']['record'] == 'example.com' + assert result['set']['prefix'] == '' + assert result['set']['ttl'] == 3600 + assert result['set']['type'] == 'A' + assert result['set']['value'] == ['1.2.3.4'] + assert 'sets' not in result + + def test_get_single_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prefix': '*', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'set' in result + assert result['set']['record'] == '*.example.com' + assert result['set']['prefix'] == '*' + assert result['set']['ttl'] == 3600 + assert result['set']['type'] == 'A' + assert result['set']['value'] == ['1.2.3.5'] + assert 'sets' not in result + + def test_get_all_for_one_record(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'what': 'all_types_for_record', + 'zone_name': 'example.com', + 'record': '*.example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'set' not in result + assert 'sets' in result + sets = result['sets'] + assert len(sets) == 2 + assert sets[0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + } + assert sets[1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + } + + def test_get_all_for_one_record_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'what': 'all_types_for_record', + 'zone_name': 'example.com.', + 'prefix': '@', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'set' not in result + assert 'sets' in result + sets = result['sets'] + assert len(sets) == 5 + assert sets[0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + } + assert sets[1] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + } + assert sets[2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + } + assert sets[3] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + } + assert sets[4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': ['hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600'], + } + + def test_get_all(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'what': 'all_records', + 'zone_id': '42', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'set' not in result + assert 'sets' in result + sets = result['sets'] + assert len(sets) == 8 + assert sets[0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + } + assert sets[1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + } + assert sets[2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + } + assert sets[3] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + } + assert sets[4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + } + assert sets[5] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + } + assert sets[6] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': ['hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600'], + } + assert sets[7] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': [u'bär "with quotes" (use \\ to escape)'], + } + + def test_get_single_txt_api(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'txt_transformation': 'api', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'set' in result + assert result['set']['record'] == 'foo.example.com' + assert result['set']['prefix'] == 'foo' + assert result['set']['ttl'] is None + assert result['set']['type'] == 'TXT' + assert result['set']['value'] == [u'bär " \\"with quotes\\"" " " "(use \\\\ to escape)"'] + assert 'sets' not in result + + def test_get_single_txt_quoted(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'txt_transformation': 'quoted', + 'txt_character_encoding': 'decimal', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'set' in result + assert result['set']['record'] == 'foo.example.com' + assert result['set']['prefix'] == 'foo' + assert result['set']['ttl'] is None + assert result['set']['type'] == 'TXT' + assert result['set']['value'] == [u'"b\\195\\164r \\"with quotes\\" (use \\\\ to escape)"'] + assert 'sets' not in result + + def test_get_single_txt_quoted_deprecation(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'txt_transformation': 'quoted', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'set' in result + assert result['set']['record'] == 'foo.example.com' + assert result['set']['prefix'] == 'foo' + assert result['set']['ttl'] is None + assert result['set']['type'] == 'TXT' + assert result['set']['value'] == [u'"b\\303\\244r \\"with quotes\\" (use \\\\ to escape)"'] + assert 'sets' not in result + assert 'deprecations' in result + found = False + for deprecation in result['deprecations']: + if 'collection_name' in deprecation and deprecation['collection_name'] != 'community.dns': + continue + found = True + assert deprecation['msg'] == ( + 'The default of the txt_character_encoding option will change from "octal" to "decimal" in community.dns 3.0.0.' + ' This potentially affects you since you use txt_transformation=quoted.' + ' You can explicitly set txt_character_encoding to "octal" to keep the current behavior,' + ' or "decimal" to already now switch to the new behavior.' + ' We recommend switching to the new behavior, and using check/diff mode to figure out potential changes' + ) + assert deprecation['version'] == '3.0.0' + assert deprecation.get('date') is None + assert found diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_sets.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_sets.py new file mode 100644 index 000000000..339fb49c5 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_sets.py @@ -0,0 +1,1236 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# 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 + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hetzner_dns_record_sets + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hetzner import ( + HETZNER_JSON_ZONE_GET_RESULT, + HETZNER_JSON_ZONE_LIST_RESULT, + HETZNER_JSON_ZONE_RECORDS_GET_RESULT, +) + + +class TestHetznerDNSRecordJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hetzner_dns_record_sets.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_id': 23, + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_id': 23, + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://dns.hetzner.com/api/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_key_collision_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_id': '42', + 'record_sets': [ + { + 'record': 'test.example.com', + 'type': 'A', + 'ignore': True, + }, + { + 'prefix': 'test', + 'type': 'A', + 'value': ['1.2.3.4'], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['msg'] == 'Found multiple sets for record test.example.com and type A: index #0 and #1' + + def test_conversion_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'TXT', + 'ttl': 3600, + 'value': [ + '"hellö', + ], + }, + ], + 'txt_transformation': 'quoted', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['msg'] == ( + 'Error while converting DNS values: While processing record from the user: Missing double quotation mark at the end of value' + ) + + def test_idempotency_empty(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_id': '42', + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + + def test_removal_prune(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prune': 'true', + 'record_sets': [ + { + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'prefix': '@', + 'ttl': 3600, + 'type': 'AAAA', + 'value': [], + }, + { + 'record': 'example.com', + 'type': 'MX', + 'ignore': True, + }, + { + 'record': 'example.com', + 'type': 'NS', + 'ignore': True, + }, + { + 'record': 'example.com', + 'type': 'SOA', + 'ignore': True, + }, + { + 'record': 'foo.example.com', + 'type': 'TXT', + 'ttl': None, + 'value': [u'bär "with quotes" (use \\ to escape)'], + }, + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/{0}'.format(127)) + .result_str(''), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/{0}'.format(128)) + .result_str(''), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert result['diff']['before'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': ['hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600'], + }, + { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': [u'bär "with quotes" (use \\ to escape)'], + }, + ], + } + assert result['diff']['after'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': None, + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': ['hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600'], + }, + { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': [u'bär "with quotes" (use \\ to escape)'], + }, + ], + } + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_id': '42', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + }, + ], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_check_mode_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_id': '42', + 'record_sets': [ + { + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + }, + ], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org xxx"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], '128 issue "letsencrypt.org xxx"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': '@', + 'value': '128 issue "letsencrypt.org xxx"', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], '128 issue "letsencrypt.org"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': '@', + 'value': '128 issue "letsencrypt.org"', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_idn_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'prefix': '☺', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], 'xn--74h') + .expect_json_value(['value'], '128 issue "letsencrypt.org"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': 'xn--74h', + 'value': '128 issue "letsencrypt.org"', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_failed(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org xxx"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], '128 issue "letsencrypt.org xxx"') + .return_header('Content-Type', 'application/json') + .result_json({'record': {}, 'error': {'code': 500, 'message': 'Internal Server Error'}}), + ]) + + assert result['msg'] == ( + 'Error: POST https://dns.hetzner.com/api/v1/records resulted in API error 500 (Internal Server Error)' + ' with error message "Internal Server Error" (error code 500)' + ) + + def test_change_add_two_failed(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org xxx"', + '128 issuewild "letsencrypt.org"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 422) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/bulk') + .expect_json_value_absent(['records', 0, 'id']) + .expect_json_value(['records', 0, 'type'], 'CAA') + .expect_json_value(['records', 0, 'ttl'], 3600) + .expect_json_value(['records', 0, 'zone_id'], '42') + .expect_json_value(['records', 0, 'name'], '@') + .expect_json_value(['records', 0, 'value'], '128 issue "letsencrypt.org xxx"') + .expect_json_value_absent(['records', 1, 'id']) + .expect_json_value(['records', 1, 'type'], 'CAA') + .expect_json_value(['records', 1, 'ttl'], 3600) + .expect_json_value(['records', 1, 'zone_id'], '42') + .expect_json_value(['records', 1, 'name'], '@') + .expect_json_value(['records', 1, 'value'], '128 issuewild "letsencrypt.org"') + .expect_json_value_absent(['records', 2]) + .return_header('Content-Type', 'application/json') + .result_json({ + 'invalid_records': [ + { + 'type': 'CAA', + 'name': '@', + 'value': '128 issue "letsencrypt.org xxx"', + 'ttl': 3600, + 'zone_id': '42', + }, + { + 'type': 'CAA', + 'name': '@', + 'value': '128 issuewild "letsencrypt.org"', + 'ttl': 3600, + 'zone_id': '42', + }, + ], + 'valid_records': [], + 'records': [], + 'error': { + 'message': 'invalid CAA record, invalid CAA record, ', + 'code': 422, + }, + }), + ]) + + assert result['msg'] == ( + 'Errors: Creating CAA record "128 issue "letsencrypt.org xxx"" with TTL 3600 for zone 42 failed with unknown reason;' + ' Creating CAA record "128 issuewild "letsencrypt.org"" with TTL 3600 for zone 42 failed with unknown reason' + ) + + def test_change_modify_list(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'NS', + 'ttl': None, + 'value': [ + 'helium.ns.hetzner.de.', + 'ytterbium.ns.hetzner.com.', + ], + }, + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/131') + .result_str(''), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/132') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value_absent(['ttl']) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'ytterbium.ns.hetzner.com.') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '131', + 'type': 'NS', + 'name': '@', + 'value': 'ytterbium.ns.hetzner.com.', + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': ['hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600'], + }, + { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': [u'bär "with quotes" (use \\ to escape)'], + }, + ], + } + assert result['diff']['after'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': None, + 'value': ['helium.ns.hetzner.de.', 'ytterbium.ns.hetzner.com.'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': ['hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600'], + }, + { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': [u'bär "with quotes" (use \\ to escape)'], + }, + ], + } + + def test_change_modify_list_ttl(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'NS', + 'ttl': 3600, + 'value': [ + 'helium.ns.hetzner.de.', + 'ytterbium.ns.hetzner.com.', + ], + }, + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/130') + .result_str(''), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/132') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'helium.ns.hetzner.de.') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '130', + 'type': 'NS', + 'name': '@', + 'value': 'ytterbium.ns.hetzner.com.', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/131') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'ytterbium.ns.hetzner.com.') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '131', + 'type': 'NS', + 'name': '@', + 'value': 'ytterbium.ns.hetzner.com.', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': ['hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600'], + }, + { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': [u'bär "with quotes" (use \\ to escape)'], + }, + ], + } + assert result['diff']['after'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 3600, + 'value': ['helium.ns.hetzner.de.', 'ytterbium.ns.hetzner.com.'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': ['hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600'], + }, + { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': [u'bär "with quotes" (use \\ to escape)'], + }, + ], + } diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_zone_info.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_zone_info.py new file mode 100644 index 000000000..473d4d1bc --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_zone_info.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# 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 + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hetzner_dns_zone_info + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hetzner import ( + HETZNER_JSON_ZONE_GET_RESULT, + HETZNER_JSON_ZONE_LIST_RESULT, +) + + +class TestHetznerDNSZoneInfoJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hetzner_dns_zone_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_zone_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .return_header('Content-Type', 'application/json; charset=utf-8') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_zone_info, { + 'hetzner_token': 'foo', + 'zone_id': '23', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .return_header('Content-Type', 'application/json; charset=utf-8') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_zone_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_zone_info, { + 'hetzner_token': 'foo', + 'zone_id': '23', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_zone_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://dns.hetzner.com/api/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_get(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_zone_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json; charset=utf-8') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['zone_name'] == 'example.com' + assert result['zone_info'] == { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + 'legacy_dns_host': 'string', + 'legacy_ns': ['bar', 'foo'], + 'ns': ['string'], + 'owner': 'Example', + 'paused': True, + 'permission': 'string', + 'project': 'string', + 'registrar': 'string', + 'status': 'verified', + 'ttl': 10800, + 'verified': '2021-07-09T11:18:37Z', + 'records_count': 0, + 'is_secondary_dns': True, + 'txt_verification': { + 'name': 'string', + 'token': 'string', + }, + } + + def test_get_id(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_zone_info, { + 'hetzner_token': 'foo', + 'zone_id': '42', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json; charset=utf-8') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['zone_name'] == 'example.com' + assert result['zone_info'] == { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + 'legacy_dns_host': 'string', + 'legacy_ns': ['bar', 'foo'], + 'ns': ['string'], + 'owner': 'Example', + 'paused': True, + 'permission': 'string', + 'project': 'string', + 'registrar': 'string', + 'status': 'verified', + 'ttl': 10800, + 'verified': '2021-07-09T11:18:37Z', + 'records_count': 0, + 'is_secondary_dns': True, + 'txt_verification': { + 'name': 'string', + 'token': 'string', + }, + } diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record.py new file mode 100644 index 000000000..c72566a2b --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record.py @@ -0,0 +1,1032 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# 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 + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hosttech_dns_record + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hosttech import ( + expect_wsdl_authentication, + expect_wsdl_value, + validate_wsdl_call, + validate_wsdl_add_request, + validate_wsdl_del_request, + create_wsdl_add_result, + create_wsdl_del_result, + HOSTTECH_WSDL_DEFAULT_ENTRIES, + HOSTTECH_WSDL_DEFAULT_ZONE_RESULT, + HOSTTECH_WSDL_ZONE_NOT_FOUND, + HOSTTECH_JSON_DEFAULT_ENTRIES, + HOSTTECH_JSON_ZONE_GET_RESULT, + HOSTTECH_JSON_ZONE_LIST_RESULT, + HOSTTECH_JSON_ZONE_RECORDS_GET_RESULT, +) + +try: + import lxml.etree + HAS_LXML_ETREE = True +except ImportError: + HAS_LXML_ETREE = False + + +@pytest.mark.skipif(not HAS_LXML_ETREE, reason="Need lxml.etree for WSDL tests") +class TestHosttechDNSRecordWSDL(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.org', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '23', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id_prefix(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_id': 23, + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '23', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_value(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.6', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_type(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_id': 42, + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '42', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_record(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.6', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_absent(self, mocker): + record = HOSTTECH_WSDL_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': record[3] + 'example.com', + 'type': record[2], + 'value': record[4], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_del_request(record), + ])) + .result_str(create_wsdl_del_result(True)), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one(self, mocker): + new_entry = (131, 42, 'CAA', 'foo', '0 issue "letsencrypt.org"', 3600, None, None) + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'foo.example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_add_request('42', new_entry), + ])) + .result_str(create_wsdl_add_result(new_entry)), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + +class TestHosttechDNSRecordJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id_prefix(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 23, + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23/records', without_query=True) + .expect_query_values('type', 'MX') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://api.ns1.hosttech.eu/api/user/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + 'extra': { + 'comment': '', + }, + } + assert result['diff']['before'] == result['diff']['after'] + + def test_idempotency_absent_value(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.6', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert result['diff']['before'] == {} + assert result['diff']['before'] == {} + + def test_idempotency_absent_value_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'prefix': '*', + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.6', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_type(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_record(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.6', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'warnings' not in result + + def test_absent_check(self, mocker): + record = HOSTTECH_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': record['name'] + 'example.com', + 'type': record['type'], + 'value': record['ipv4'], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_absent(self, mocker): + record = HOSTTECH_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': record['name'] + 'example.com', + 'type': record['type'], + 'value': record['ipv4'], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/{0}'.format(record['id'])) + .result_str(''), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 42, + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_check_mode_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 42, + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_diff': True, + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records', without_query=True) + .expect_query_values('type', 'CAA') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == {} + assert result['diff']['after'] == { + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + 'extra': {}, + } + + def test_change_add_one(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '128 issue "letsencrypt.org xxx"', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], '') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org xxx') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': '', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org xxx', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == {} + assert result['diff']['after'] == { + 'prefix': '', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '128 issue "letsencrypt.org xxx"', + 'extra': { + 'comment': '', + }, + } + + def test_change_add_one_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': '128 issue "letsencrypt.org"', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], '') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': '', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_idn_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '☺', + 'type': 'CAA', + 'ttl': 3600, + 'value': '128 issue "letsencrypt.org"', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], 'xn--74h') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': 'xn--74h', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_modify_check(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 300, + 'value': '1.2.3.5', + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_modify(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 300, + 'value': '1.2.3.5', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/126') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 300) + .expect_json_value(['name'], '*') + .expect_json_value(['ipv4'], '1.2.3.5') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': '126', + 'type': 'A', + 'name': '*', + 'ipv4': '1.2.3.5', + 'ttl': 300, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_info.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_info.py new file mode 100644 index 000000000..73c9d16a1 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_info.py @@ -0,0 +1,766 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# 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 + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import patch + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hosttech_dns_record_info + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hosttech import ( + expect_wsdl_authentication, + expect_wsdl_value, + validate_wsdl_call, + HOSTTECH_WSDL_DEFAULT_ZONE_RESULT, + HOSTTECH_WSDL_ZONE_NOT_FOUND, + HOSTTECH_JSON_ZONE_GET_RESULT, + HOSTTECH_JSON_ZONE_LIST_RESULT, +) + +try: + import lxml.etree + HAS_LXML_ETREE = True +except ImportError: + HAS_LXML_ETREE = False + + +def mock_sleep(delay): + pass + + +@pytest.mark.skipif(not HAS_LXML_ETREE, reason="Need lxml.etree for WSDL tests") +class TestHosttechDNSRecordInfoWSDL(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.org', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '23', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_get_single(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert len(result['records']) == 1 + assert result['records'][0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.4', + 'extra': { + 'comment': '', + }, + } + + def test_get_all_for_one_record(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'what': 'all_types_for_record', + 'zone_id': 42, + 'record': '*.example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '42', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert len(result['records']) == 2 + assert result['records'][0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.5', + 'extra': { + 'comment': '', + }, + } + assert result['records'][1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::4', + 'extra': { + 'comment': '', + }, + } + + def test_get_all(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'what': 'all_records', + 'zone_name': 'example.com.', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert len(result['records']) == 8 + assert result['records'][0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.4', + 'extra': { + 'comment': '', + }, + } + assert result['records'][1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.5', + 'extra': { + 'comment': '', + }, + } + assert result['records'][2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::3', + 'extra': { + 'comment': '', + }, + } + assert result['records'][3] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::4', + 'extra': { + 'comment': '', + }, + } + assert result['records'][4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': '10 example.com', + 'extra': { + 'comment': '', + }, + } + assert result['records'][5] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns3.hostserv.eu', + 'extra': { + 'comment': '', + }, + } + assert result['records'][6] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns2.hostserv.eu', + 'extra': { + 'comment': '', + }, + } + assert result['records'][7] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns1.hostserv.eu', + 'extra': { + 'comment': '', + }, + } + + +class TestHosttechDNSRecordInfoJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://api.ns1.hosttech.eu/api/user/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_too_many_retries(self, mocker): + sleep_values = [5, 10, 1, 1, 1, 60, 10, 1, 10, 3.1415] + + def sleep_check(delay): + expected = sleep_values.pop(0) + assert delay == expected + + with patch('time.sleep', sleep_check): + result = self.run_module_failed(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '1') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '0') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '-1') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '61') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', 'foo') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '0.9') + .result_str(''), + FetchUrlCall('GET', 429) + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '3.1415') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '42') + .result_str(''), + ]) + print(sleep_values) + assert result['msg'] == 'Error: Stopping after 10 failed retries with 429 Too Many Attempts' + assert len(sleep_values) == 0 + + def test_get_single(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert len(result['records']) == 1 + assert result['records'][0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.4', + 'extra': { + 'comment': '', + }, + } + + def test_get_single_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'prefix': '*', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert len(result['records']) == 1 + assert result['records'][0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.5', + 'extra': { + 'comment': '', + }, + } + + def test_get_all_for_one_record(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'what': 'all_types_for_record', + 'zone_name': 'example.com', + 'record': '*.example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert len(result['records']) == 2 + assert result['records'][0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.5', + 'extra': { + 'comment': '', + }, + } + assert result['records'][1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::4', + 'extra': { + 'comment': '', + }, + } + + def test_get_all_for_one_record_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'what': 'all_types_for_record', + 'zone_name': 'example.com.', + 'prefix': '', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert len(result['records']) == 6 + assert result['records'][0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.4', + 'extra': { + 'comment': '', + }, + } + assert result['records'][1] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::3', + 'extra': { + 'comment': '', + }, + } + assert result['records'][2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': '10 example.com', + 'extra': { + 'comment': '', + }, + } + assert result['records'][3] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns3.hostserv.eu', + 'extra': { + 'comment': '', + }, + } + assert result['records'][4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns2.hostserv.eu', + 'extra': { + 'comment': '', + }, + } + assert result['records'][5] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns1.hostserv.eu', + 'extra': { + 'comment': '', + }, + } + + def test_get_all(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'what': 'all_records', + 'zone_id': 42, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert len(result['records']) == 8 + assert result['records'][0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.4', + 'extra': { + 'comment': '', + }, + } + assert result['records'][1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.5', + 'extra': { + 'comment': '', + }, + } + assert result['records'][2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::3', + 'extra': { + 'comment': '', + }, + } + assert result['records'][3] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::4', + 'extra': { + 'comment': '', + }, + } + assert result['records'][4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': '10 example.com', + 'extra': { + 'comment': '', + }, + } + assert result['records'][5] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns3.hostserv.eu', + 'extra': { + 'comment': '', + }, + } + assert result['records'][6] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns2.hostserv.eu', + 'extra': { + 'comment': '', + }, + } + assert result['records'][7] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns1.hostserv.eu', + 'extra': { + 'comment': '', + }, + } diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_set.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_set.py new file mode 100644 index 000000000..579d58091 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_set.py @@ -0,0 +1,1899 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# 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 + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hosttech_dns_record_set + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hosttech import ( + expect_wsdl_authentication, + expect_wsdl_value, + validate_wsdl_call, + validate_wsdl_add_request, + validate_wsdl_update_request, + validate_wsdl_del_request, + create_wsdl_add_result, + create_wsdl_update_result, + create_wsdl_del_result, + HOSTTECH_WSDL_DEFAULT_ENTRIES, + HOSTTECH_WSDL_DEFAULT_ZONE_RESULT, + HOSTTECH_WSDL_ZONE_NOT_FOUND, + HOSTTECH_JSON_DEFAULT_ENTRIES, + HOSTTECH_JSON_ZONE_GET_RESULT, + HOSTTECH_JSON_ZONE_LIST_RESULT, + HOSTTECH_JSON_ZONE_RECORDS_GET_RESULT, +) + +try: + import lxml.etree + HAS_LXML_ETREE = True +except ImportError: + HAS_LXML_ETREE = False + + +@pytest.mark.skipif(not HAS_LXML_ETREE, reason="Need lxml.etree for WSDL tests") +class TestHosttechDNSRecordWSDL(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record_set.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.org', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '23', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id_prefix(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_id': 23, + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '23', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_value(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_ttl(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 1800, + 'value': [ + '1.2.3.5', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_type(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_id': 42, + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '42', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_record(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_absent(self, mocker): + record = HOSTTECH_WSDL_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': record[3] + 'example.com', + 'type': record[2], + 'ttl': record[5], + 'value': [ + record[4], + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_del_request(record), + ])) + .result_str(create_wsdl_del_result(True)), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one(self, mocker): + new_entry = (131, 42, 'CAA', 'foo', '0 issue "letsencrypt.org"', 3600, None, None) + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'foo.example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_add_request('42', new_entry), + ])) + .result_str(create_wsdl_add_result(new_entry)), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_modify_list_fail(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + 'on_existing': 'keep_and_fail', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['msg'] == "Record already exists with different value. Set on_existing=replace to replace it" + + def test_change_modify_list(self, mocker): + del_entry = (130, 42, 'NS', '', 'ns3.hostserv.eu', 10800, None, None) + update_entry = (131, 42, 'NS', '', 'ns4.hostserv.eu', 10800, None, None) + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_del_request(del_entry), + ])) + .result_str(create_wsdl_del_result(True)), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_update_request(update_entry), + ])) + .result_str(create_wsdl_update_result(update_entry)), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns1.hostserv.eu', 'ns2.hostserv.eu', 'ns3.hostserv.eu'], + } + assert result['diff']['after'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns1.hostserv.eu', 'ns4.hostserv.eu'], + } + + +class TestHosttechDNSRecordJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record_set.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id_prefix(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 23, + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23/records', without_query=True) + .expect_query_values('type', 'MX') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://api.ns1.hosttech.eu/api/user/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': ['10 example.com'], + } + assert result['diff']['before'] == result['diff']['after'] + + def test_idempotency_absent_value(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert result['diff']['before'] == { + 'record': '*.example.com', + 'prefix': '*', + 'type': 'A', + 'ttl': 3600, + 'value': ['1.2.3.5'], + } + assert result['diff']['before'] == result['diff']['after'] + + def test_idempotency_absent_value_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'prefix': '*', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_ttl(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 1800, + 'value': [ + '1.2.3.5', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_type(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_record(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'warnings' not in result + + def test_idempotency_absent_record_warn(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep_and_warn', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert list(result['warnings']) == ["Record already exists with different value. Set on_existing=replace to remove it"] + + def test_idempotency_absent_record_fail(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep_and_fail', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['msg'] == "Record already exists with different value. Set on_existing=replace to remove it" + + def test_absent(self, mocker): + record = HOSTTECH_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': record['name'] + 'example.com', + 'type': record['type'], + 'ttl': record['ttl'], + 'value': [ + record['ipv4'], + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/{0}'.format(record['id'])) + .result_str(''), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_absent_bulk(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'value': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/130') + .result_str(''), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/131') + .result_str(''), + # Record 132 has been deleted between querying and we trying to delete it + FetchUrlCall('DELETE', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/132') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'record does not exist'}), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_absent_bulk_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'value': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/130') + .result_str(''), + FetchUrlCall('DELETE', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/131') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'Internal Server Error'}), + ]) + + assert result['msg'] == ( + 'Error: Expected HTTP status 204, 404 for DELETE https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/131,' + ' but got HTTP status 500 (Internal Server Error) with message "Internal Server Error"' + ) + + def test_absent_other_value(self, mocker): + record = HOSTTECH_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': record['name'] + 'example.com', + 'type': record['type'], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/{0}'.format(record['id'])) + .result_str(''), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 42, + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_check_mode_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 42, + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + '_ansible_diff': True, + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records', without_query=True) + .expect_query_values('type', 'CAA') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == {} + assert result['diff']['after'] == { + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': ['0 issue "letsencrypt.org"'], + } + + def test_change_add_one(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org xxx"', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], '') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org xxx') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': '', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org xxx', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], '') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': '', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_idn_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '☺', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], 'xn--74h') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': 'xn--74h', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_fail(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '☺', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], 'xn--74h') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'Internal Server Error'}), + ]) + + assert result['msg'] == ( + 'Error: Expected HTTP status 201 for POST https://api.ns1.hosttech.eu/api/user/v1/zones/42/records,' + ' but got HTTP status 500 (Internal Server Error) with message "Internal Server Error"' + ) + + def test_change_modify_list_fail(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + 'on_existing': 'keep_and_fail', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['msg'] == "Record already exists with different value. Set on_existing=replace to replace it" + + def test_change_modify_list_warn(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + 'on_existing': 'keep_and_warn', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns1.hostserv.eu', 'ns2.hostserv.eu', 'ns3.hostserv.eu'], + } + assert result['diff']['after'] == result['diff']['before'] + assert list(result['warnings']) == ["Record already exists with different value. Set on_existing=replace to replace it"] + + def test_change_modify_list_keep(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + 'on_existing': 'keep', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert 'warnings' not in result + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns1.hostserv.eu', 'ns2.hostserv.eu', 'ns3.hostserv.eu'], + } + assert result['diff']['after'] == result['diff']['before'] + + def test_change_modify_list(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/130') + .result_str(''), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/131') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'ns4.hostserv.eu') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 131, + 'type': 'NS', + 'ownername': '', + 'targetname': 'ns4.hostserv.eu', + 'ttl': 10800, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns1.hostserv.eu', 'ns2.hostserv.eu', 'ns3.hostserv.eu'], + } + assert result['diff']['after'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns1.hostserv.eu', 'ns4.hostserv.eu'], + } + + def test_change_modify_bulk(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'a1', + 'a2', + 'a3', + 'a4', + 'a5', + 'a6', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/132') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a1') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 132, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a1', + 'ttl': 10800, + 'comment': '', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/131') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a2') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 131, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a2', + 'ttl': 10800, + 'comment': '', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/130') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a3') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 130, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a3', + 'ttl': 10800, + 'comment': '', + }, + }), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a4') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 300, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a4', + 'ttl': 10800, + 'comment': '', + }, + }), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a5') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 301, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a5', + 'ttl': 10800, + 'comment': '', + }, + }), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a6') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 302, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a6', + 'ttl': 10800, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' not in result + + def test_change_modify_bulk_errors_update(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'a1', + 'a2', + 'a3', + 'a4', + 'a5', + 'a6', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('PUT', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/132') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a1') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'Internal Server Error'}), + ]) + + assert result['msg'] == ( + 'Error: Expected HTTP status 200 for PUT https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/132,' + ' but got HTTP status 500 (Internal Server Error) with message "Internal Server Error"' + ) + + def test_change_modify_bulk_errors_create(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'a1', + 'a2', + 'a3', + 'a4', + 'a5', + 'a6', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/132') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a1') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 132, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a1', + 'ttl': 10800, + 'comment': '', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/131') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a2') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 131, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a2', + 'ttl': 10800, + 'comment': '', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/130') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a3') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 130, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a3', + 'ttl': 10800, + 'comment': '', + }, + }), + FetchUrlCall('POST', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a4') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'Internal Server Error'}), + ]) + + assert result['msg'] == ( + 'Error: Expected HTTP status 201 for POST https://api.ns1.hosttech.eu/api/user/v1/zones/42/records,' + ' but got HTTP status 500 (Internal Server Error) with message "Internal Server Error"' + ) diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_set_info.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_set_info.py new file mode 100644 index 000000000..35fd53730 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_set_info.py @@ -0,0 +1,649 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# 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 + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import patch + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hosttech_dns_record_set_info + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hosttech import ( + expect_wsdl_authentication, + expect_wsdl_value, + validate_wsdl_call, + HOSTTECH_WSDL_DEFAULT_ZONE_RESULT, + HOSTTECH_WSDL_ZONE_NOT_FOUND, + HOSTTECH_JSON_ZONE_GET_RESULT, + HOSTTECH_JSON_ZONE_LIST_RESULT, +) + +try: + import lxml.etree + HAS_LXML_ETREE = True +except ImportError: + HAS_LXML_ETREE = False + + +def mock_sleep(delay): + pass + + +@pytest.mark.skipif(not HAS_LXML_ETREE, reason="Need lxml.etree for WSDL tests") +class TestHosttechDNSRecordSetInfoWSDL(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record_set_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.org', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '23', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_get_single(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'set' in result + assert result['set']['record'] == 'example.com' + assert result['set']['prefix'] == '' + assert result['set']['ttl'] == 3600 + assert result['set']['type'] == 'A' + assert result['set']['value'] == ['1.2.3.4'] + assert 'sets' not in result + + def test_get_all_for_one_record(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'what': 'all_types_for_record', + 'zone_id': 42, + 'record': '*.example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '42', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'set' not in result + assert 'sets' in result + sets = result['sets'] + assert len(sets) == 2 + assert sets[0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + } + assert sets[1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + } + + def test_get_all(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'what': 'all_records', + 'zone_name': 'example.com.', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'set' not in result + assert 'sets' in result + sets = result['sets'] + assert len(sets) == 6 + assert sets[0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + } + assert sets[1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + } + assert sets[2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + } + assert sets[3] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + } + assert sets[4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + } + assert sets[5] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + } + + +class TestHosttechDNSRecordSetInfoJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record_set_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://api.ns1.hosttech.eu/api/user/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_too_many_retries(self, mocker): + sleep_values = [5, 10, 1, 1, 1, 60, 10, 1, 10, 3.1415] + + def sleep_check(delay): + expected = sleep_values.pop(0) + assert delay == expected + + with patch('time.sleep', sleep_check): + result = self.run_module_failed(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '1') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '0') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '-1') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '61') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', 'foo') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '0.9') + .result_str(''), + FetchUrlCall('GET', 429) + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '3.1415') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '42') + .result_str(''), + ]) + print(sleep_values) + assert result['msg'] == 'Error: Stopping after 10 failed retries with 429 Too Many Attempts' + assert len(sleep_values) == 0 + + def test_get_single(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'set' in result + assert result['set']['record'] == 'example.com' + assert result['set']['prefix'] == '' + assert result['set']['ttl'] == 3600 + assert result['set']['type'] == 'A' + assert result['set']['value'] == ['1.2.3.4'] + assert 'sets' not in result + + def test_get_single_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'prefix': '*', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'set' in result + assert result['set']['record'] == '*.example.com' + assert result['set']['prefix'] == '*' + assert result['set']['ttl'] == 3600 + assert result['set']['type'] == 'A' + assert result['set']['value'] == ['1.2.3.5'] + assert 'sets' not in result + + def test_get_all_for_one_record(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'what': 'all_types_for_record', + 'zone_name': 'example.com', + 'record': '*.example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'set' not in result + assert 'sets' in result + sets = result['sets'] + assert len(sets) == 2 + assert sets[0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + } + assert sets[1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + } + + def test_get_all_for_one_record_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'what': 'all_types_for_record', + 'zone_name': 'example.com.', + 'prefix': '', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'set' not in result + assert 'sets' in result + sets = result['sets'] + assert len(sets) == 4 + assert sets[0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + } + assert sets[1] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + } + assert sets[2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + } + assert sets[3] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + } + + def test_get_all(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'what': 'all_records', + 'zone_id': 42, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'set' not in result + assert 'sets' in result + sets = result['sets'] + assert len(sets) == 6 + assert sets[0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + } + assert sets[1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + } + assert sets[2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + } + assert sets[3] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + } + assert sets[4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + } + assert sets[5] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + } diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_sets.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_sets.py new file mode 100644 index 000000000..8c609b3af --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_sets.py @@ -0,0 +1,1429 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# 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 + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hosttech_dns_record_sets + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hosttech import ( + expect_wsdl_authentication, + expect_wsdl_value, + validate_wsdl_call, + validate_wsdl_add_request, + validate_wsdl_update_request, + validate_wsdl_del_request, + create_wsdl_add_result, + create_wsdl_update_result, + create_wsdl_del_result, + HOSTTECH_WSDL_DEFAULT_ZONE_RESULT, + HOSTTECH_WSDL_ZONE_NOT_FOUND, + HOSTTECH_JSON_ZONE_GET_RESULT, + HOSTTECH_JSON_ZONE_LIST_RESULT, +) + +try: + import lxml.etree + HAS_LXML_ETREE = True +except ImportError: + HAS_LXML_ETREE = False + + +@pytest.mark.skipif(not HAS_LXML_ETREE, reason="Need lxml.etree for WSDL tests") +class TestHosttechDNSRecordWSDL(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record_sets.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.org', + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.org', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_id': 23, + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '23', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'prefix': '', + 'type': 'A', + 'value': '1.2.3.4', + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + } + ], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one(self, mocker): + new_entry = (131, 42, 'CAA', 'foo', '0 issue "letsencrypt.org"', 3600, None, None) + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'foo.example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + } + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_add_request('42', new_entry), + ])) + .result_str(create_wsdl_add_result(new_entry)), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_modify_list(self, mocker): + del_entry = (130, 42, 'NS', '', 'ns3.hostserv.eu', 10800, None, None) + update_entry = (131, 42, 'NS', '', 'ns4.hostserv.eu', 10800, None, None) + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + }, + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_del_request(del_entry), + ])) + .result_str(create_wsdl_del_result(True)), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_update_request(update_entry), + ])) + .result_str(create_wsdl_update_result(update_entry)), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + }, + ], + } + assert result['diff']['after'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns1.hostserv.eu', 'ns4.hostserv.eu'], + }, + ], + } + + +class TestHosttechDNSRecordJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record_sets.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_id': 23, + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_id': 23, + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://api.ns1.hosttech.eu/api/user/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_key_collision_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_id': 42, + 'record_sets': [ + { + 'record': 'test.example.com', + 'type': 'A', + 'ignore': True, + }, + { + 'prefix': 'test', + 'type': 'A', + 'value': ['1.2.3.4'], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['msg'] == 'Found multiple sets for record test.example.com and type A: index #0 and #1' + + def test_idempotency_empty(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_id': 42, + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_removal_prune(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'prune': 'true', + 'record_sets': [ + { + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': [], + }, + { + 'record': 'example.com', + 'type': 'MX', + 'ignore': True, + }, + { + 'record': 'example.com', + 'type': 'NS', + 'ignore': True, + }, + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/{0}'.format(127)) + .result_str(''), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/{0}'.format(128)) + .result_str(''), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert result['diff']['before'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + }, + ], + } + assert result['diff']['after'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + }, + ], + } + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_id': 42, + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + }, + ], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_check_mode_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_id': 42, + 'record_sets': [ + { + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + }, + ], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_failed(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org xxx"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], '') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org xxx') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'Internal Server Error'}), + ]) + + assert result['msg'] == ( + 'Error: Expected HTTP status 201 for POST https://api.ns1.hosttech.eu/api/user/v1/zones/42/records,' + ' but got HTTP status 500 (Internal Server Error) with message "Internal Server Error"' + ) + + def test_change_add_one(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org xxx"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], '') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org xxx') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': '', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org xxx', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], '') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': '', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_idn_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'prefix': '☺', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], 'xn--74h') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': 'xn--74h', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_modify_list(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + }, + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/130') + .result_str(''), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/131') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'ns4.hostserv.eu') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 131, + 'type': 'NS', + 'ownername': '', + 'targetname': 'ns4.hostserv.eu', + 'ttl': 10800, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + }, + ], + } + assert result['diff']['after'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns1.hostserv.eu', 'ns4.hostserv.eu'], + }, + ], + } + + def test_change_modify_list_nodelete(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns2.hostserv.eu', + ], + }, + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/130') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'record does not exist'}), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + }, + ], + } + assert result['diff']['after'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns2.hostserv.eu', 'ns1.hostserv.eu'], + }, + ], + } + + def test_change_modify_list_ttl(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'NS', + 'ttl': 3600, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + }, + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/130') + .result_str(''), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/132') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'ns1.hostserv.eu') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 130, + 'type': 'NS', + 'ownername': '', + 'targetname': 'ns4.hostserv.eu', + 'ttl': 3600, + 'comment': '', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/131') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'ns4.hostserv.eu') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 131, + 'type': 'NS', + 'ownername': '', + 'targetname': 'ns4.hostserv.eu', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + }, + ], + } + assert result['diff']['after'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 3600, + 'value': ['ns1.hostserv.eu', 'ns4.hostserv.eu'], + }, + ], + } diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_zone_info.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_zone_info.py new file mode 100644 index 000000000..e95aebe3a --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_zone_info.py @@ -0,0 +1,351 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# 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 + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hosttech_dns_zone_info + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hosttech import ( + expect_wsdl_authentication, + expect_wsdl_value, + validate_wsdl_call, + HOSTTECH_WSDL_DEFAULT_ZONE_RESULT, + HOSTTECH_WSDL_ZONE_NOT_FOUND, + HOSTTECH_JSON_ZONE_GET_RESULT, + HOSTTECH_JSON_ZONE_2_GET_RESULT, + HOSTTECH_JSON_ZONE_LIST_RESULT, +) + +try: + import lxml.etree + HAS_LXML_ETREE = True +except ImportError: + HAS_LXML_ETREE = False + + +@pytest.mark.skipif(not HAS_LXML_ETREE, reason="Need lxml.etree for WSDL tests") +class TestHosttechDNSZoneInfoWSDL(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_zone_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_zone_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.org', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.org', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_zone_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_id': 23, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '23', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_get(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_zone_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert result['zone_name'] == 'example.com' + + def test_get_id(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_zone_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_id': '42', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '42', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert result['zone_name'] == 'example.com' + assert result['zone_info'] == { + 'email': 'dns@hosttech.eu', + 'ttl': 10800, + } + + +class TestHosttechDNSZoneInfoJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_zone_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_zone_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_zone_info, { + 'hosttech_token': 'foo', + 'zone_id': 23, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_zone_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_zone_info, { + 'hosttech_token': 'foo', + 'zone_id': 23, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_zone_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://api.ns1.hosttech.eu/api/user/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_get(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_zone_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert result['zone_name'] == 'example.com' + assert result['zone_info'] == { + 'email': 'test@example.com', + 'ttl': 10800, + 'dnssec': False, + 'dnssec_email': None, + 'ds_records': None, + } + + def test_get_id(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_zone_info, { + 'hosttech_token': 'foo', + 'zone_id': 42, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert result['zone_name'] == 'example.com' + assert result['zone_info'] == { + 'email': 'test@example.com', + 'ttl': 10800, + 'dnssec': False, + 'dnssec_email': None, + 'ds_records': None, + } + + def test_get_dnssec(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_zone_info, { + 'hosttech_token': 'foo', + 'zone_name': 'foo.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'foo.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/43') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_2_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 43 + assert result['zone_name'] == 'foo.com' + assert result['zone_info'] == { + 'email': 'test@foo.com', + 'ttl': 10800, + 'dnssec': True, + 'dnssec_email': 'test@foo.com', + 'ds_records': [ + { + 'key_tag': 12345, + 'algorithm': 8, + 'digest_type': 1, + 'digest': '012356789ABCDEF0123456789ABCDEF012345678', + 'flags': 257, + 'protocol': 3, + 'public_key': + 'MuhdzsQdqEGShwjtJDKZZjdKqUSGluFzTTinpuEeIRzLLcgkwgAPKWFa ' + 'eQntNlmcNDeCziGwpdvhJnvKXEMbFcZwsaDIJuWqERxAQNGABWfPlCLh ' + 'HQPnbpRPNKipSdBaUhuOubvFvjBpFAwiwSAapRDVsAgKvjXucfXpFfYb ' + 'pCundbAXBWhbpHVbqgmGoixXzFSwUsGVYLPpBCiDlLJwzjRKYYaoVYge ' + 'kMtKFYUVnWIKbectWkDFdVqXwkKigCUDiuTTJxOBRJRNzGiDNMWBjYSm ' + 'bBCAHMaMYaghLbYTwyKXltdHTHwBwtswGNfpnEdSpKFzZJonBZArQfHD ' + 'lfceKgmKwEF=', + }, + { + 'key_tag': 12345, + 'algorithm': 8, + 'digest_type': 2, + 'digest': '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF', + 'flags': 257, + 'protocol': 3, + 'public_key': + 'MuhdzsQdqEGShwjtJDKZZjdKqUSGluFzTTinpuEeIRzLLcgkwgAPKWFa ' + 'eQntNlmcNDeCziGwpdvhJnvKXEMbFcZwsaDIJuWqERxAQNGABWfPlCLh ' + 'HQPnbpRPNKipSdBaUhuOubvFvjBpFAwiwSAapRDVsAgKvjXucfXpFfYb ' + 'pCundbAXBWhbpHVbqgmGoixXzFSwUsGVYLPpBCiDlLJwzjRKYYaoVYge ' + 'kMtKFYUVnWIKbectWkDFdVqXwkKigCUDiuTTJxOBRJRNzGiDNMWBjYSm ' + 'bBCAHMaMYaghLbYTwyKXltdHTHwBwtswGNfpnEdSpKFzZJonBZArQfHD ' + 'lfceKgmKwEF=', + } + ], + } diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_wait_for_txt.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_wait_for_txt.py new file mode 100644 index 000000000..829d6465e --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_wait_for_txt.py @@ -0,0 +1,1425 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# 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 + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import MagicMock, patch + +from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( + set_module_args, + ModuleTestCase, + AnsibleExitJson, + AnsibleFailJson, +) + +from ansible_collections.community.dns.plugins.modules import wait_for_txt + +from ..module_utils.resolver_helper import ( + mock_resolver, + mock_query_udp, + create_mock_answer, + create_mock_response, +) + +# We need dnspython +dns = pytest.importorskip('dns') + + +def mock_sleep(delay): + pass + + +def mock_monotonic(call_sequence): + def f(): + assert len(call_sequence) > 0, 'monotonic() was called more often than expected' + value = call_sequence[0] + del call_sequence[0] + return value + + return f + + +class TestWaitForTXT(ModuleTestCase): + def test_single(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.AAAA, '1:2::3'), + )), + }, + { + 'target': 'ns.example.org', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '4.4.4.4'), + )), + }, + { + 'target': 'ns.example.org', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('1:2::3', '3.3.3.3'): [ + { + 'target': dns.name.from_unicode(u'example.org'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'asdf'), + )), + }, + ], + ('4.4.4.4', ): [ + { + 'target': dns.name.from_unicode(u'example.org'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'asdf'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'www.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + ), dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME, 'example.org') + )]), + }, + { + 'query_target': dns.name.from_unicode(u'org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.org'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.org'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleExitJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'www.example.com', + 'values': [ + 'asdf', + ] + }, + ], + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['changed'] is False + assert exc.value.args[0]['completed'] == 1 + assert len(exc.value.args[0]['records']) == 1 + assert exc.value.args[0]['records'][0]['name'] == 'www.example.com' + assert exc.value.args[0]['records'][0]['done'] is True + assert exc.value.args[0]['records'][0]['values'] == { + 'ns.example.com': ['asdf'], + 'ns.example.org': ['asdf'], + } + assert exc.value.args[0]['records'][0]['check_count'] == 1 + + def test_double(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', ): [ + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'fdsa'), + )), + }, + { + 'target': dns.name.from_unicode(u'mail.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'mail.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"any bar"'), + )), + }, + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'fdsa'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'asdf'), + )), + }, + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'asdf'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'www.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, authority=[dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'mail.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, authority=[dns.rrset.from_rdata( + 'mail.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleExitJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'www.example.com', + 'values': [ + 'asdf', + ], + 'mode': 'equals', + }, + { + 'name': 'mail.example.com', + 'values': [ + 'foo bar', + 'any bar', + ], + 'mode': 'superset', + }, + ], + 'timeout': 10, + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['changed'] is False + assert exc.value.args[0]['completed'] == 2 + assert len(exc.value.args[0]['records']) == 2 + assert exc.value.args[0]['records'][0]['name'] == 'www.example.com' + assert exc.value.args[0]['records'][0]['done'] is True + assert exc.value.args[0]['records'][0]['values'] == { + 'ns.example.com': ['asdf'], + } + assert exc.value.args[0]['records'][0]['check_count'] == 3 + assert exc.value.args[0]['records'][1]['name'] == 'mail.example.com' + assert exc.value.args[0]['records'][1]['done'] is True + assert exc.value.args[0]['records'][1]['values'] == { + 'ns.example.com': ['any bar'], + } + assert exc.value.args[0]['records'][1]['check_count'] == 1 + + def test_subset(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', ): [ + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'as df'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"another one"'), + )), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"foo bar"'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"another one"'), + )), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"foo bar"'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"another one"'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'as df'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleExitJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'example.com', + 'values': [ + 'asdf', + 'asdf', + 'foo bar', + ], + 'mode': 'subset', + }, + ], + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['changed'] is False + assert exc.value.args[0]['completed'] == 1 + assert len(exc.value.args[0]['records']) == 1 + assert exc.value.args[0]['records'][0]['name'] == 'example.com' + assert exc.value.args[0]['records'][0]['done'] is True + assert exc.value.args[0]['records'][0]['values'] == { + 'ns.example.com': ['foo bar', 'another one', 'asdf'], + } + assert exc.value.args[0]['records'][0]['check_count'] == 3 + + def test_superset(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', ): [ + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"bumble bee"'), + )), + }, + { + 'target': dns.name.from_unicode(u'mail.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(), + }, + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'fdsa'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'asdf'), + )), + }, + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'asdf ""'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'bee'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'www.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, authority=[dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'mail.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, authority=[dns.rrset.from_rdata( + 'mail.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleExitJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'www.example.com', + 'values': [ + 'asdf', + 'bee', + ], + 'mode': 'superset', + }, + { + 'name': 'mail.example.com', + 'values': [ + 'foo bar', + 'any bar', + ], + 'mode': 'superset', + }, + ], + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['changed'] is False + assert exc.value.args[0]['completed'] == 2 + assert len(exc.value.args[0]['records']) == 2 + assert exc.value.args[0]['records'][0]['name'] == 'www.example.com' + assert exc.value.args[0]['records'][0]['done'] is True + assert exc.value.args[0]['records'][0]['values'] == { + 'ns.example.com': ['asdf', 'bee'], + } + assert exc.value.args[0]['records'][0]['check_count'] == 3 + assert exc.value.args[0]['records'][1]['name'] == 'mail.example.com' + assert exc.value.args[0]['records'][1]['done'] is True + assert exc.value.args[0]['records'][1]['values'] == { + 'ns.example.com': [], + } + assert exc.value.args[0]['records'][1]['check_count'] == 1 + + def test_superset_not_empty(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', ): [ + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"bumble bee"'), + )), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'bumble'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'bee'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'wizard'), + )), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'bumble'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'bee'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleExitJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'example.com', + 'values': [ + 'bumble', + 'bee', + ], + 'mode': 'superset_not_empty', + }, + ], + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['changed'] is False + assert exc.value.args[0]['completed'] == 1 + assert len(exc.value.args[0]['records']) == 1 + assert exc.value.args[0]['records'][0]['name'] == 'example.com' + assert exc.value.args[0]['records'][0]['done'] is True + assert exc.value.args[0]['records'][0]['values'] == { + 'ns.example.com': ['bumble', 'bee'], + } + assert exc.value.args[0]['records'][0]['check_count'] == 4 + + def test_equals(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', ): [ + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"bumble bee"'), + )), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'bumble bee'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'wizard'), + )), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"bumble bee"'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'wizard'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'foo'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleExitJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'example.com', + 'values': [ + 'foo', + 'bumble bee', + 'wizard', + ], + 'mode': 'equals', + }, + ], + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['changed'] is False + assert exc.value.args[0]['completed'] == 1 + assert len(exc.value.args[0]['records']) == 1 + assert exc.value.args[0]['records'][0]['name'] == 'example.com' + assert exc.value.args[0]['records'][0]['done'] is True + assert exc.value.args[0]['records'][0]['values'] == { + 'ns.example.com': ['bumble bee', 'wizard', 'foo'], + } + assert exc.value.args[0]['records'][0]['check_count'] == 4 + + def test_equals_ordered(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', ): [ + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"bumble bee"'), + )), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"bumble bee"'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'wizard'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'foo'), + )), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'foo'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"bumble bee"'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'wizard'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleExitJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'example.com', + 'values': [ + 'foo', + 'bumble bee', + 'wizard', + ], + 'mode': 'equals_ordered', + }, + ], + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['changed'] is False + assert exc.value.args[0]['completed'] == 1 + assert len(exc.value.args[0]['records']) == 1 + assert exc.value.args[0]['records'][0]['name'] == 'example.com' + assert exc.value.args[0]['records'][0]['done'] is True + assert exc.value.args[0]['records'][0]['values'] == { + 'ns.example.com': ['foo', 'bumble bee', 'wizard'], + } + assert exc.value.args[0]['records'][0]['check_count'] == 4 + + def test_timeout(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', ): [ + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'fdsa'), + )), + }, + { + 'target': dns.name.from_unicode(u'mail.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'mail.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"any bar"'), + )), + }, + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'fdsa'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'asdf'), + )), + }, + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'asdfasdf'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'www.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, authority=[dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'mail.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, authority=[dns.rrset.from_rdata( + 'mail.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with patch('ansible_collections.community.dns.plugins.modules.wait_for_txt.monotonic', + mock_monotonic([0, 0.01, 1.2, 6.013, 7.41, 12.021])): + with pytest.raises(AnsibleFailJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'www.example.com', + 'values': [ + 'asdf', + ], + 'mode': 'equals', + }, + { + 'name': 'mail.example.com', + 'values': [ + 'foo bar', + 'any bar', + ], + 'mode': 'superset', + }, + ], + 'timeout': 12, + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['msg'] == 'Timeout (1 out of 2 check(s) passed).' + assert exc.value.args[0]['completed'] == 1 + assert len(exc.value.args[0]['records']) == 2 + assert exc.value.args[0]['records'][0]['name'] == 'www.example.com' + assert exc.value.args[0]['records'][0]['done'] is False + assert exc.value.args[0]['records'][0]['values'] == { + 'ns.example.com': ['asdfasdf'], + } + assert exc.value.args[0]['records'][0]['check_count'] == 3 + assert exc.value.args[0]['records'][1]['name'] == 'mail.example.com' + assert exc.value.args[0]['records'][1]['done'] is True + assert exc.value.args[0]['records'][1]['values'] == { + 'ns.example.com': ['any bar'], + } + assert exc.value.args[0]['records'][1]['check_count'] == 1 + + def test_nxdomain(self): + resolver = mock_resolver(['1.1.1.1'], {}) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NXDOMAIN), + }, + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NXDOMAIN), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with patch('ansible_collections.community.dns.plugins.modules.wait_for_txt.monotonic', + mock_monotonic([0, 0.01, 1.2, 6.013])): + with pytest.raises(AnsibleFailJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'www.example.com', + 'values': [ + 'asdf', + ], + }, + ], + 'timeout': 2, + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['msg'] == 'Timeout (0 out of 1 check(s) passed).' + assert exc.value.args[0]['completed'] == 0 + assert len(exc.value.args[0]['records']) == 1 + assert exc.value.args[0]['records'][0]['name'] == 'www.example.com' + assert exc.value.args[0]['records'][0]['done'] is False + assert exc.value.args[0]['records'][0]['values'] == {} + assert exc.value.args[0]['records'][0]['check_count'] == 2 + + def test_servfail(self): + resolver = mock_resolver(['1.1.1.1'], {}) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.SERVFAIL), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleFailJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'www.example.com', + 'values': [ + 'asdf', + ], + }, + ], + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['msg'] == 'Unexpected resolving error: Error SERVFAIL while querying 1.1.1.1 with query get NS for "com."' + assert exc.value.args[0]['completed'] == 0 + assert len(exc.value.args[0]['records']) == 1 + assert exc.value.args[0]['records'][0]['name'] == 'www.example.com' + assert exc.value.args[0]['records'][0]['done'] is False + assert 'values' not in exc.value.args[0]['records'][0] + assert exc.value.args[0]['records'][0]['check_count'] == 0 + + def test_cname_loop(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + { + 'target': 'ns.example.org', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '4.4.4.4'), + )), + }, + { + 'target': 'ns.example.org', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'www.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + ), dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME, 'example.org') + )]), + }, + { + 'query_target': dns.name.from_unicode(u'org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.org'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.org'), + ), dns.rrset.from_rdata( + 'example.org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME, 'www.example.com') + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleFailJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'www.example.com', + 'values': [ + 'asdf', + ], + }, + ], + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['msg'] == 'Unexpected resolving error: Found CNAME loop starting at www.example.com' + assert exc.value.args[0]['completed'] == 0 + assert len(exc.value.args[0]['records']) == 1 + assert exc.value.args[0]['records'][0]['name'] == 'www.example.com' + assert exc.value.args[0]['records'][0]['done'] is False + assert 'values' not in exc.value.args[0]['records'][0] + assert exc.value.args[0]['records'][0]['check_count'] == 0 diff --git a/ansible_collections/community/dns/tests/unit/plugins/plugin_utils/test_public_suffix.py b/ansible_collections/community/dns/tests/unit/plugins/plugin_utils/test_public_suffix.py new file mode 100644 index 000000000..984fd7600 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/plugin_utils/test_public_suffix.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# 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 + +# Note that this file contains some public domain test data from +# https://raw.githubusercontent.com/publicsuffix/list/master/tests/test_psl.txt +# The data is marked and documented as public domain appropriately. + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.dns.plugins.plugin_utils.public_suffix import ( + PublicSuffixList, + PUBLIC_SUFFIX_LIST, +) + + +TEST_GET_SUFFIX = [ + ('', {}, {}, '', ''), + ('.', {}, {}, '', ''), + ('foo.com', {}, {}, 'com', 'foo.com'), + ('bar.foo.com.', {}, {}, 'com.', 'foo.com.'), + ('BaR.fOo.CoM.', {'normalize_result': True}, {}, 'com.', 'foo.com.'), + ('BaR.fOo.CoM.', {}, {}, 'CoM.', 'fOo.CoM.'), + ('com', {}, {}, 'com', ''), + ('com', {}, {'only_if_registerable': False}, 'com', 'com'), + ('com', {'keep_unknown_suffix': False}, {}, 'com', ''), + ('foo.com', {}, {}, 'com', 'foo.com'), + ('foo.com', {'keep_unknown_suffix': False}, {}, 'com', 'foo.com'), + ('foobarbaz', {}, {}, 'foobarbaz', ''), + ('foobarbaz', {}, {'only_if_registerable': False}, 'foobarbaz', 'foobarbaz'), + ('foobarbaz', {'keep_unknown_suffix': False}, {}, '', ''), + ('foo.foobarbaz', {}, {}, 'foobarbaz', 'foo.foobarbaz'), + ('foo.foobarbaz', {'keep_unknown_suffix': False}, {}, '', ''), + ('-a.com', {}, {}, '', ''), # invalid domain name (leading dash in label) + ('a-.com', {}, {}, '', ''), # invalid domain name (trailing dash in label) + ('-.com', {}, {}, '', ''), # invalid domain name (leading and trailing dash in label) + ('.com', {}, {}, '', ''), # invalid domain name (empty label) + ('test.cloudfront.net', {}, {}, 'cloudfront.net', 'test.cloudfront.net'), # private rule + ('test.cloudfront.net', {'icann_only': True}, {}, 'net', 'cloudfront.net'), +] + + +@pytest.mark.parametrize("domain, kwargs, reg_extra_kwargs, suffix, reg_domain", TEST_GET_SUFFIX) +def test_get_suffix(domain, kwargs, reg_extra_kwargs, suffix, reg_domain): + assert PUBLIC_SUFFIX_LIST.get_suffix(domain, **kwargs) == suffix + kwargs.update(reg_extra_kwargs) + assert PUBLIC_SUFFIX_LIST.get_registrable_domain(domain, **kwargs) == reg_domain + + +# ------------------------------------------------------------------------------------------------- +# The following list is taken from https://raw.githubusercontent.com/publicsuffix/list/master/tests/test_psl.txt +# Any copyright for this list is dedicated to the Public Domain. (https://creativecommons.org/publicdomain/zero/1.0/) +# This list has been provided by Rob Stradling of Comodo (see last section on https://publicsuffix.org/list/). +TEST_SUFFIX_OFFICIAL_TESTS = [ + # '' input. + ('', '', {}), + # Mixed case. + ('COM', '', {}), + ('example.COM', 'example.com', {'normalize_result': True}), + ('WwW.example.COM', 'example.com', {'normalize_result': True}), + ('example.COM', 'example.COM', {}), + ('WwW.example.COM', 'example.COM', {}), + # Leading dot. + ('.com', '', {}), + ('.example', '', {}), + ('.example.com', '', {}), + ('.example.example', '', {}), + # Unlisted TLD. + ('example', '', {}), + ('example.example', 'example.example', {}), + ('b.example.example', 'example.example', {}), + ('a.b.example.example', 'example.example', {}), + # Listed, but non-Internet, TLD. + # ('local', '', {}), + # ('example.local', '', {}), + # ('b.example.local', '', {}), + # ('a.b.example.local', '', {}), + # TLD with only 1 rule. + ('biz', '', {}), + ('domain.biz', 'domain.biz', {}), + ('b.domain.biz', 'domain.biz', {}), + ('a.b.domain.biz', 'domain.biz', {}), + # TLD with some 2-level rules. + ('com', '', {}), + ('example.com', 'example.com', {}), + ('b.example.com', 'example.com', {}), + ('a.b.example.com', 'example.com', {}), + ('uk.com', '', {}), + ('example.uk.com', 'example.uk.com', {}), + ('b.example.uk.com', 'example.uk.com', {}), + ('a.b.example.uk.com', 'example.uk.com', {}), + ('test.ac', 'test.ac', {}), + # TLD with only 1 (wildcard) rule. + ('mm', '', {}), + ('c.mm', '', {}), + ('b.c.mm', 'b.c.mm', {}), + ('a.b.c.mm', 'b.c.mm', {}), + # More complex TLD. + ('jp', '', {}), + ('test.jp', 'test.jp', {}), + ('www.test.jp', 'test.jp', {}), + ('ac.jp', '', {}), + ('test.ac.jp', 'test.ac.jp', {}), + ('www.test.ac.jp', 'test.ac.jp', {}), + ('kyoto.jp', '', {}), + ('test.kyoto.jp', 'test.kyoto.jp', {}), + ('ide.kyoto.jp', '', {}), + ('b.ide.kyoto.jp', 'b.ide.kyoto.jp', {}), + ('a.b.ide.kyoto.jp', 'b.ide.kyoto.jp', {}), + ('c.kobe.jp', '', {}), + ('b.c.kobe.jp', 'b.c.kobe.jp', {}), + ('a.b.c.kobe.jp', 'b.c.kobe.jp', {}), + ('city.kobe.jp', 'city.kobe.jp', {}), + ('www.city.kobe.jp', 'city.kobe.jp', {}), + # TLD with a wildcard rule and exceptions. + ('ck', '', {}), + ('test.ck', '', {}), + ('b.test.ck', 'b.test.ck', {}), + ('a.b.test.ck', 'b.test.ck', {}), + ('www.ck', 'www.ck', {}), + ('www.www.ck', 'www.ck', {}), + # US K12. + ('us', '', {}), + ('test.us', 'test.us', {}), + ('www.test.us', 'test.us', {}), + ('ak.us', '', {}), + ('test.ak.us', 'test.ak.us', {}), + ('www.test.ak.us', 'test.ak.us', {}), + ('k12.ak.us', '', {}), + ('test.k12.ak.us', 'test.k12.ak.us', {}), + ('www.test.k12.ak.us', 'test.k12.ak.us', {}), + # IDN labels. + (u'食狮.com.cn', u'食狮.com.cn', {}), + (u'食狮.公司.cn', u'食狮.公司.cn', {}), + (u'www.食狮.公司.cn', u'食狮.公司.cn', {}), + (u'shishi.公司.cn', u'shishi.公司.cn', {}), + (u'公司.cn', u'', {}), + (u'食狮.中国', u'食狮.中国', {}), + (u'www.食狮.中国', u'食狮.中国', {}), + (u'shishi.中国', u'shishi.中国', {}), + (u'中国', u'', {}), + # Same as above, but punycoded. (TODO: punycode not supported yet!) + ('xn--85x722f.com.cn', 'xn--85x722f.com.cn', {}), + ('xn--85x722f.xn--55qx5d.cn', 'xn--85x722f.xn--55qx5d.cn', {}), + ('www.xn--85x722f.xn--55qx5d.cn', 'xn--85x722f.xn--55qx5d.cn', {}), + ('shishi.xn--55qx5d.cn', 'shishi.xn--55qx5d.cn', {}), + ('xn--55qx5d.cn', '', {}), + ('xn--85x722f.xn--fiqs8s', 'xn--85x722f.xn--fiqs8s', {}), + ('www.xn--85x722f.xn--fiqs8s', 'xn--85x722f.xn--fiqs8s', {}), + ('shishi.xn--fiqs8s', 'shishi.xn--fiqs8s', {}), + ('xn--fiqs8s', '', {}), +] +# End of public domain test data +# ------------------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize("domain, registrable_domain, kwargs", TEST_SUFFIX_OFFICIAL_TESTS) +def test_get_suffix_official(domain, registrable_domain, kwargs): + reg_domain = PUBLIC_SUFFIX_LIST.get_registrable_domain(domain, **kwargs) + assert reg_domain == registrable_domain + + +def test_load_psl_dot(tmpdir): + fn = tmpdir / 'psl.dat' + fn.write('''// ===BEGIN BLA BLA DOMAINS=== +.com. +// ===END BLA BLA DOMAINS==='''.encode('utf-8')) + psl = PublicSuffixList.load(str(fn)) + assert len(psl._rules) == 1 + rule = psl._rules[0] + assert rule.labels == ('com', ) + assert rule.exception_rule is False + assert rule.part == 'bla bla' + + +def test_load_psl_no_part(tmpdir): + fn = tmpdir / 'psl.dat' + fn.write('''// ===BEGIN BLA BLA DOMAINS=== +com +// ===END BLA BLA DOMAINS=== +net'''.encode('utf-8')) + with pytest.raises(Exception) as excinfo: + PublicSuffixList.load(str(fn)) + assert str(excinfo.value) == 'Internal error: found PSL entry with no part!' diff --git a/ansible_collections/community/dns/tests/unit/requirements.txt b/ansible_collections/community/dns/tests/unit/requirements.txt new file mode 100644 index 000000000..8b7c7cd61 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/requirements.txt @@ -0,0 +1,11 @@ +# Copyright (c) Ansible Project +# 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 + +unittest2 ; python_version < '2.7' +importlib ; python_version < '2.7' + +dnspython + +lxml < 4.3.0 ; python_version < '2.7' # lxml 4.3.0 and later require python 2.7 or later +lxml ; python_version >= '2.7' diff --git a/ansible_collections/community/dns/tests/unit/requirements.yml b/ansible_collections/community/dns/tests/unit/requirements.yml new file mode 100644 index 000000000..586a6a1b3 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/requirements.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# 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 + +collections: +- community.internal_test_tools |