diff --git a/CHANGELOG.md b/CHANGELOG.md index ce14745..1c658eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## UNRELEASED + +#### Nothworthy Changes + +* Added support for more record types: ALIAS, DS, NAPTR, TLSA, TLSA +* Added support for root nameservers + ## v0.0.1 - 2022-01-14 - Moving #### Nothworthy Changes diff --git a/README.md b/README.md index 9a34e36..9e115eb 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,15 @@ providers: #### Records -TransipProvider A, AAAA, CAA, CNAME, MX, NS, SRV, SPF, SSHFP, and TXT +TransipProvider A, AAAA, ALIAS, CAA, CNAME, DS, MX, NAPTR, NS, SPF, SRV, SSHFP, TLSA, TXT + +#### Root NS records + +TransipProvider support root NS record management. +**notes:** + - Transip currently only supports FQDN values for root nameservers. + - Transip has no TTL for root nameservers, so the TTL value from the source is ignored + #### Dynamic diff --git a/octodns_transip/__init__.py b/octodns_transip/__init__.py index b2f422f..1bc32c0 100644 --- a/octodns_transip/__init__.py +++ b/octodns_transip/__init__.py @@ -5,11 +5,13 @@ from collections import defaultdict, namedtuple from logging import getLogger +from requests.utils import is_ipv4_address from transip import TransIP from transip.exceptions import TransIPHTTPError -from transip.v6.objects import DnsEntry +from transip.v6.objects import DnsEntry, Nameserver +from urllib3.util.ssl_ import is_ipaddress -from octodns.provider import ProviderException +from octodns.provider import ProviderException, SupportsException from octodns.provider.base import BaseProvider from octodns.record import Record @@ -31,17 +33,53 @@ class TransipNewZoneException(TransipException): pass +class TransipRetrieveRecordsException(ProviderException): + pass + + +class TransipRetrieveNameserverException(ProviderException): + pass + + +class TransipSaveRecordsException(ProviderException): + pass + + +class TransipSaveNameserverException(ProviderException): + pass + + class TransipProvider(BaseProvider): SUPPORTS_GEO = False SUPPORTS_DYNAMIC = False + SUPPORTS_ROOT_NS = True SUPPORTS = set( - ('A', 'AAAA', 'CNAME', 'MX', 'NS', 'SRV', 'SPF', 'TXT', 'SSHFP', 'CAA') + ( + 'A', + 'AAAA', + 'CNAME', + 'MX', + 'NS', + 'SRV', + 'SPF', + 'TXT', + 'SSHFP', + 'CAA', + 'TLSA', + 'NAPTR', + 'ALIAS', + 'DS', + ) ) - # unsupported by OctoDNS: 'TLSA' MIN_TTL = 120 TIMEOUT = 15 ROOT_RECORD = '@' + # TransIP root nameservers don't have TTL configurable. + # This value is enforced on root NS records to prevent TTL-only changes + # See root NS handling in _process_desired_zone for more information + ROOT_NS_TTL = 3600 + def __init__(self, id, account, key=None, key_file=None, *args, **kwargs): self.log = getLogger('TransipProvider[{}]'.format(id)) self.log.debug('__init__: id=%s, account=%s, token=***', id, account) @@ -68,10 +106,9 @@ def populate(self, zone, target=False, lenient=False): ) before = len(zone.records) - try: domain = self._client.domains.get(zone.name.strip('.')) - records = domain.dns.list() + except TransIPHTTPError as e: if e.response_code == 404 and target is False: # Zone not found in account, and not a target so just @@ -93,9 +130,61 @@ def populate(self, zone, target=False, lenient=False): ) ) - self.log.debug( - 'populate: found %s records for zone %s', len(records), zone.name - ) + # Retrieve dns records from transip api + try: + records = domain.dns.list() + self.log.debug( + 'populate: found %s records for zone %s', + len(records), + zone.name, + ) + except TransIPHTTPError as e: + self.log.error('populate: (%s) %s ', e.response_code, e.message) + raise TransipRetrieveRecordsException( + ( + 'populate: ({}) failed to get ' + 'dns records for zone: {}' + ).format(e.response_code, zone.name) + ) + + # Retrieve nameservers from transip api + try: + nameservers = domain.nameservers.list() + self.log.debug( + 'populate: found %s root nameservers for zone %s', + len(nameservers), + zone.name, + ) + except TransIPHTTPError as e: + self.log.error('populate: (%s) %s ', e.response_code, e.message) + raise TransipRetrieveNameserverException( + ( + 'populate: ({}) failed to get ' + + 'root nameservers for zone: {}' + ).format(e.response_code, zone.name) + ) + + # If nameservers are found, add them as ROOT NS records + if nameservers: + values = [] + for ns in nameservers: + if ns.hostname != '': + values.append(ns.hostname + '.') + if ns.ipv4 != '': + values.append(ns.ipv4 + '.') + if ns.ipv6 != '': + values.append(ns.ipv6 + '.') + + record = Record.new( + zone, + '', + {'type': 'NS', 'ttl': self.ROOT_NS_TTL, 'values': values}, + source=self, + lenient=lenient, + ) + zone.add_record(record, lenient=lenient) + zone.root_ns + + # If records are found, add them to the zone if records: values = defaultdict(lambda: defaultdict(list)) for record in records: @@ -116,12 +205,41 @@ def populate(self, zone, target=False, lenient=False): lenient=lenient, ) zone.add_record(record, lenient=lenient) + self.log.info( 'populate: found %s records', len(zone.records) - before ) return True + def _process_desired_zone(self, desired): + + for record in desired.records: + if record._type == 'NS' and record.name == '': + + # Check values for FQDN, IP's are not supported + values = record.values + + for value in values: + if is_ipaddress(value.strip(".")): + msg = f'ip address not supported for root NS value for {record.fqdn}' + raise SupportsException(f'{self.id}: {msg}') + + # TransIP root nameservers don't have TTL configurable. + # Check if TTL differs and enforce our fixed value if needed. + if record.ttl != self.ROOT_NS_TTL: + updated_record = record.copy() + updated_record.ttl = self.ROOT_NS_TTL + msg = f'TTL value not supported for root NS values for {record.fqdn}' + fallback = f'modified to fixed value ({self.ROOT_NS_TTL})' + # Not using self.supports_warn_or_except(msg, fallback) + # because strict_mode shouldn't be disabled just for an ignored value + # so always return a warning even in strict_mode + self.log.warning('%s; %s', msg, fallback) + desired.add_record(updated_record, replace=True) + + return super()._process_desired_zone(desired) + def _apply(self, plan): desired = plan.desired changes = plan.changes @@ -135,11 +253,40 @@ def _apply(self, plan): 'Unhandled error: ({}) {}'.format(e.response_code, e.message) ) + for change in changes: + record = change.new + + if record.name == '' and record._type == 'NS': + values = record.values + + nameservers = [] + for value in values: + nameservers.append( + Nameserver( + domain.nameservers, _attr_for_nameserver(value) + ) + ) + try: + domain.nameservers.replace(nameservers) + except TransIPHTTPError as e: + self.log.warning( + '_apply: Set Nameservers returned one or more errors: {}'.format( + e + ) + ) + raise TransipSaveNameserverException( + 'Unhandled error: ({}) {}'.format( + e.response_code, e.message + ) + ) + records = [] for record in plan.desired.records: if record._type in self.SUPPORTS: # Root records have '@' as name name = record.name + if name == '' and record._type == 'NS': + continue if name == '': name = self.ROOT_RECORD @@ -154,13 +301,13 @@ def _apply(self, plan): self.log.warning( '_apply: Set DNS returned one or more errors: {}'.format(e) ) - raise TransipException( + raise TransipSaveRecordsException( 'Unhandled error: ({}) {}'.format(e.response_code, e.message) ) def _data_for(type_, records, current_zone): - if type_ == 'CNAME': + if type_ == 'CNAME' or type_ == 'ALIAS': return { 'type': type_, 'ttl': records[0].expire, @@ -198,12 +345,51 @@ def format_caa(record): def format_txt(record): return record.content.replace(';', '\\;') + def format_tlsa(record): + ( + certificate_usage, + selector, + matching_type, + certificate_association_data, + ) = record.content.split(' ', 4) + return { + 'certificate_usage': certificate_usage, + 'selector': selector, + 'matching_type': matching_type, + 'certificate_association_data': certificate_association_data, + } + + def format_naptr(record): + order, preference, flags, service, regexp, replacement = ( + record.content.split(' ', 6) + ) + return { + 'order': order, + 'preference': preference, + 'flags': flags, + 'service': service, + 'regexp': regexp, + 'replacement': replacement, + } + + def format_ds(record): + key_tag, algorithm, digest_type, digest = record.content.split(' ', 4) + return { + 'key_tag': key_tag, + 'algorithm': algorithm, + 'digest_type': digest_type, + 'digest': digest, + } + value_formatter = { 'MX': format_mx, 'SRV': format_srv, 'SSHFP': format_sshfp, 'CAA': format_caa, 'TXT': format_txt, + 'TLSA': format_tlsa, + 'NAPTR': format_naptr, + 'DS': format_ds, }.get(type_, lambda r: r.content) return { @@ -231,16 +417,56 @@ def _get_lowest_ttl(records): def _entries_for(name, record): values = record.values if hasattr(record, 'values') else [record.value] + + def entry_mx(v): + return f'{v.preference} {v.exchange}' + + def entry_srv(v): + return f'{v.priority} {v.weight} {v.port} {v.target}' + + def entry_sshfp(v): + return f'{v.algorithm} {v.fingerprint_type} {v.fingerprint}' + + def entry_caa(v): + return f'{v.flags} {v.tag} {v.value}' + + def entry_txt(v): + return v.replace('\\;', ';') + + def entry_tlsa(v): + return f'{v.certificate_usage} {v.selector} {v.matching_type} {v.certificate_association_data}' + + def entry_naptr(v): + return f'{v.order} {v.preference} {v.flags} {v.service} {v.regexp} {v.replacement}' + + def entry_ds(v): + return f'{v.key_tag} {v.algorithm} {v.digest_type} {v.digest}' + formatter = { - 'MX': lambda v: f'{v.preference} {v.exchange}', - 'SRV': lambda v: f'{v.priority} {v.weight} {v.port} {v.target}', - 'SSHFP': lambda v: ( - f'{v.algorithm} {v.fingerprint_type} {v.fingerprint}' - ), - 'CAA': lambda v: f'{v.flags} {v.tag} {v.value}', - 'TXT': lambda v: v.replace('\\;', ';'), + 'MX': entry_mx, + 'SRV': entry_srv, + 'SSHFP': entry_sshfp, + 'CAA': entry_caa, + 'TXT': entry_txt, + 'TLSA': entry_tlsa, + 'NAPTR': entry_naptr, + 'DS': entry_ds, }.get(record._type, lambda r: r) + return [ DNSEntry(name, record.ttl, record._type, formatter(value)) for value in values ] + + +def _attr_for_nameserver(nameserver): + nameserver = nameserver.strip('.') + return { + 'hostname': nameserver if not is_ipaddress(nameserver) else '', + 'ipv4': nameserver if is_ipv4_address(nameserver) else '', + 'ipv6': ( + nameserver + if is_ipaddress(nameserver) and not is_ipv4_address(nameserver) + else '' + ), + } diff --git a/tests/config/unit.tests.yaml b/tests/config/unit.tests.yaml index f37d71d..b700ac2 100644 --- a/tests/config/unit.tests.yaml +++ b/tests/config/unit.tests.yaml @@ -18,6 +18,9 @@ values: - 1.2.3.4 - 1.2.3.5 + - ttl: 3600 + type: ALIAS + value: www.example.com. - ttl: 3600 type: SSHFP values: @@ -29,8 +32,9 @@ fingerprint_type: 1 - type: NS values: - - 2.2.2.2. - - 3.3.3.3. + - ns0.transip.net. + - ns1.transip.nl. + - ns2.transip.eu. - type: CAA values: - flags: 0 @@ -76,6 +80,18 @@ dname: ttl: 300 type: DNAME value: unit.tests. +ds: + ttl: 300 + type: DS + values: + - algorithm: 1 + digest: 'abcdef0123456' + digest_type: 2 + key_tag: 0 + - algorithm: 1 + digest: 'abcdef0123456' + digest_type: 2 + key_tag: 1 excluded: octodns: excluded: @@ -162,6 +178,28 @@ sub: values: - 6.2.3.4. - 7.2.3.4. +sub.txt: + type: 'NS' + values: + - ns1.test. + - ns2.test. +subzone: + type: 'NS' + values: + - 192.0.2.1. + - 192.0.2.8. +tlsa: + tll: 600 + type: 'TLSA' + values: + - certificate_association_data: ABABABABABABABABAB + certificate_usage: 1 + matching_type: 1 + selector: 1 + - certificate_association_data: ABABABABABABABABAC + certificate_usage: 2 + matching_type: 2 + selector: 0 txt: ttl: 600 type: TXT diff --git a/tests/test_provider_octodns_transip.py b/tests/test_provider_octodns_transip.py index b7dde00..0c2d48c 100644 --- a/tests/test_provider_octodns_transip.py +++ b/tests/test_provider_octodns_transip.py @@ -7,17 +7,24 @@ from unittest import TestCase from unittest.mock import Mock, patch -from transip.exceptions import TransIPHTTPError - from octodns.provider.yaml import YamlProvider +from octodns.record import Record from octodns.zone import Zone from octodns_transip import ( DNSEntry, + Nameserver, + SupportsException, TransipConfigException, TransipException, + TransIPHTTPError, TransipNewZoneException, TransipProvider, + TransipRetrieveNameserverException, + TransipRetrieveRecordsException, + TransipSaveNameserverException, + TransipSaveRecordsException, + _attr_for_nameserver, _entries_for, _parse_to_fqdn, ) @@ -51,17 +58,92 @@ def make_mock(): return zone, api_entries +def make_mock_with_nameservers(): + zone = make_expected() + + # Turn Zone.records into TransIP DNSEntries + api_entries = [] + root_ns_entries = [] + for record in zone.records: + if record._type in TransipProvider.SUPPORTS: + # Root records have '@' as name + name = record.name + if name == "" and record._type == "NS": + for value in ( + record.values + if hasattr(record, 'values') + else [record.value] + ): + root_ns_entries.append( + Nameserver( + service={}, attrs=_attr_for_nameserver(value) + ) + ) + continue + if name == "": + name = TransipProvider.ROOT_RECORD + + api_entries.extend(_entries_for(name, record)) + + # Append bogus entry so test for record type not being in SUPPORTS is + # executed. For 100% test coverage. + api_entries.append(DNSEntry("@", "3600", "BOGUS", "ns.transip.nl")) + + return zone, api_entries, root_ns_entries + + def make_mock_empty(): mock = Mock() mock.return_value.domains.get.return_value.dns.list.return_value = [] + mock.return_value.domains.get.return_value.nameservers.list.return_value = ( + [] + ) return mock +def make_mock_nameservers(): + nameservers = [] + for value in [ + 'ns0.transip.net', + 'ns1.transip.nl', + 'ns2.transip.eu', + "2.2.2.2", + "2601:644:500:e210:62f8:1dff:feb8:947a", + ]: + nameservers.append( + Nameserver(service={}, attrs=_attr_for_nameserver(value)) + ) + + return nameservers + + def make_failing_mock(response_code): mock = Mock() mock.return_value.domains.get.side_effect = [ TransIPHTTPError(str(response_code), response_code) ] + mock.return_value.domains.get.return_value.dns.list.side_effect = [ + TransIPHTTPError(str(response_code), response_code) + ] + mock.return_value.domains.get.return_value.nameservers.list.side_effect = [ + TransIPHTTPError(str(response_code), response_code) + ] + return mock + + +def make_failing_mock_records(response_code): + mock = make_mock_empty() + mock.return_value.domains.get.return_value.dns.list.side_effect = [ + TransIPHTTPError(str(response_code), response_code) + ] + return mock + + +def make_failing_mock_nameservers(response_code): + mock = make_mock_empty() + mock.return_value.domains.get.return_value.nameservers.list.side_effect = [ + TransIPHTTPError(str(response_code), response_code) + ] return mock @@ -81,6 +163,7 @@ def test_init(self): # Those should work TransipProvider("test", "unittest", key=self.bogus_key) TransipProvider("test", "unittest", key_file="/fake/path") + TransipProvider("test", "unittest", key_file="/fake/path") @patch("octodns_transip.TransIP", make_failing_mock(401)) def test_populate_unauthenticated(self): @@ -100,6 +183,28 @@ def test_populate_new_zone_as_target(self): with self.assertRaises(TransipNewZoneException): provider.populate(zone, True) + @patch("octodns_transip.TransIP", make_failing_mock_records(404)) + def test_populate_records_get_error(self): + # Happy Plan - Error while retreiving nameservers + # Will trigger an exception if provider is used as a target for a + # non-existing zone + + provider = TransipProvider("test", "unittest", self.bogus_key) + zone = Zone("unit.tests.", []) + with self.assertRaises(TransipRetrieveRecordsException): + provider.populate(zone, False) + + @patch("octodns_transip.TransIP", make_failing_mock_nameservers(404)) + def test_populate_nameserver_get_error(self): + # Happy Plan - Error while retreiving nameservers + # Will trigger an exception if provider is used as a target for a + # non-existing zone + + provider = TransipProvider("test", "unittest", self.bogus_key) + zone = Zone("unit.tests.", []) + with self.assertRaises(TransipRetrieveNameserverException): + provider.populate(zone, False) + @patch("octodns_transip.TransIP", make_mock_empty()) def test_populate_new_zone_not_target(self): # Happy Plan - Zone does not exists @@ -121,10 +226,14 @@ def test_populate_zone_does_not_exist(self): @patch("octodns_transip.TransIP") def test_populate_zone_exists_not_target(self, mock_client): # Happy Plan - Populate - source_zone, api_records = make_mock() + source_zone, api_records, root_ns_entries = make_mock_with_nameservers() mock_client.return_value.domains.get.return_value.dns.list.return_value = ( api_records ) + mock_client.return_value.domains.get.return_value.nameservers.list.return_value = ( + root_ns_entries + ) + provider = TransipProvider("test", "unittest", self.bogus_key) zone = Zone("unit.tests.", []) @@ -155,6 +264,39 @@ def test_populate_zone_exists_as_target(self): exists = provider.populate(zone, True) self.assertTrue(exists, "populate should return True") + @patch("octodns_transip.TransIP") + def test_populate_nameservers(self, mock_client): + # Happy Plan - Zone loads + + mock_client.return_value.domains.get.return_value.nameservers.list.return_value = ( + make_mock_nameservers() + ) + + provider = TransipProvider("test", "unittest", self.bogus_key) + zone = Zone("unit.tests.", []) + success = provider.populate(zone, False, lenient=True) + self.assertTrue(success, "populate should return True") + + self.assertEqual( + 1, len(zone.records), "zone.records should have 1 record" + ) + + firstRecord = zone.records.pop() + + self.assertEqual(firstRecord._type, "NS", "Record type should be NS") + self.assertEqual(firstRecord.ttl, 3600, "TTL should be 3600") + self.assertEqual( + firstRecord.values, + [ + '2.2.2.2.', + '2601:644:500:e210:62f8:1dff:feb8:947a.', + 'ns0.transip.net.', + 'ns1.transip.nl.', + 'ns2.transip.eu.', + ], + "Values should match list", + ) + @patch("octodns_transip.TransIP", make_mock_empty()) def test_plan(self): # Test happy plan, only create @@ -165,16 +307,14 @@ def test_plan(self): plan = provider.plan(make_expected()) self.assertIsNotNone(plan) - self.assertEqual(15, plan.change_counts["Create"]) + self.assertEqual(22, plan.change_counts["Create"]) self.assertEqual(0, plan.change_counts["Update"]) self.assertEqual(0, plan.change_counts["Delete"]) @patch("octodns_transip.TransIP") def test_apply(self, client_mock): # Test happy flow. Create all supported records - domain_mock = Mock() - client_mock.return_value.domains.get.return_value = domain_mock - domain_mock.dns.list.return_value = [] + provider = TransipProvider( "test", "unittest", self.bogus_key, strict_supports=False ) @@ -183,7 +323,8 @@ def test_apply(self, client_mock): self.assertIsNotNone(plan) provider.apply(plan) - domain_mock.dns.replace.assert_called_once() + client_mock.return_value.domains.get.return_value.dns.replace.assert_called_once() + client_mock.return_value.domains.get.return_value.nameservers.replace.assert_called_once() # These are the supported ones from tests/config/unit.test.yaml expected_entries = [ @@ -193,6 +334,12 @@ def test_apply(self, client_mock): "type": "A", "content": "9.9.9.9", }, + { + "name": "@", + "expire": 3600, + "type": "ALIAS", + "content": "www.example.com.", + }, { "name": "@", "expire": 3600, @@ -211,6 +358,18 @@ def test_apply(self, client_mock): "type": "NS", "content": "7.2.3.4.", }, + { + "name": "naptr", + "expire": 600, + "type": "NAPTR", + "content": "10 100 S SIP+D2U !^.*$!sip:info@bar.example.com! .", + }, + { + "name": "naptr", + "expire": 600, + "type": "NAPTR", + "content": "100 100 U SIP+D2U !^.*$!sip:info@bar.example.com! .", + }, { "name": "spf", "expire": 600, @@ -241,6 +400,42 @@ def test_apply(self, client_mock): "type": "SRV", "content": "0 0 0 .", }, + { + "name": "sub.txt", + "expire": 3600, + "type": "NS", + "content": "ns1.test.", + }, + { + "name": "sub.txt", + "expire": 3600, + "type": "NS", + "content": "ns2.test.", + }, + { + "name": "subzone", + "expire": 3600, + "type": "NS", + "content": "192.0.2.1.", + }, + { + "name": "subzone", + "expire": 3600, + "type": "NS", + "content": "192.0.2.8.", + }, + { + "name": "tlsa", + "expire": 3600, + "type": "TLSA", + "content": "1 1 1 ABABABABABABABABAB", + }, + { + "name": "tlsa", + "expire": 3600, + "type": "TLSA", + "content": "2 0 2 ABABABABABABABABAC", + }, { "name": "txt", "expire": 600, @@ -331,23 +526,150 @@ def test_apply(self, client_mock): "type": "SSHFP", "content": "1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73", }, + { + "name": "ds", + "expire": 300, + "type": "DS", + "content": "0 1 2 abcdef0123456", + }, + { + "name": "ds", + "expire": 300, + "type": "DS", + "content": "1 1 2 abcdef0123456", + }, ] + # Unpack from the transip library magic structure... seen_entries = [ e.__dict__["_attrs"] - for e in domain_mock.dns.replace.mock_calls[0][1][0] + for e in client_mock.return_value.domains.get.return_value.dns.replace.mock_calls[ + 0 + ][ + 1 + ][ + 0 + ] ] self.assertEqual( sorted(expected_entries, key=itemgetter("name", "type", "expire")), sorted(seen_entries, key=itemgetter("name", "type", "expire")), ) + seen_nameservers = [ + e.__dict__["_attrs"] + for e in client_mock.return_value.domains.get.return_value.nameservers.replace.mock_calls[ + 0 + ][ + 1 + ][ + 0 + ] + ] + expected_nameservers = [ + {'hostname': 'ns0.transip.net', 'ipv4': '', 'ipv6': ''}, + {'hostname': 'ns1.transip.nl', 'ipv4': '', 'ipv6': ''}, + {'hostname': 'ns2.transip.eu', 'ipv4': '', 'ipv6': ''}, + ] + self.assertEqual( + sorted( + expected_nameservers, key=itemgetter("hostname", "ipv4", "ipv6") + ), + sorted( + seen_nameservers, key=itemgetter("hostname", "ipv4", "ipv6") + ), + ) + + @patch("octodns_transip.TransIP") + def test_plan_ipv4_nameservers(self, client_mock): + provider = TransipProvider( + "test", "unittest", self.bogus_key, strict_supports=False + ) + + expected = make_expected() + + record = Record.new( + expected, + "", + { + 'type': "NS", + 'ttl': 3600, + 'values': ['2601:644:500:e210:62f8:1dff:feb8:947a.'], + }, + lenient=True, + ) + + expected.add_record(record, replace=True) + + with self.assertRaises(SupportsException): + plan = provider.plan(expected) + self.assertIsNotNone(plan) + + @patch("octodns_transip.TransIP") + def test_plan_ipv6_nameservers(self, client_mock): + provider = TransipProvider( + "test", "unittest", self.bogus_key, strict_supports=False + ) + + expected = make_expected() + + record = Record.new( + expected, + "", + {'type': "NS", 'ttl': 3600, 'values': ['2.2.2.2.', '3.3.3.3.']}, + ) + + expected.add_record(record, replace=True) + + with self.assertRaises(SupportsException): + plan = provider.plan(expected) + self.assertIsNotNone(plan) + + @patch("octodns_transip.TransIP") + def test_apply_nameservers_ttl_only(self, client_mock): + + x, entries, nameservers = make_mock_with_nameservers() + + client_mock.return_value.domains.get.return_value.nameservers.list.return_value = ( + nameservers + ) + + provider = TransipProvider( + "test", "unittest", self.bogus_key, strict_supports=False + ) + provider.ROOT_NS_TTL = 0 # Enforce a diff from unit-test root NS ttl + + plan = provider.plan(make_expected()) + self.assertIsNotNone(plan) + provider.apply(plan) + + client_mock.return_value.domains.get.return_value.dns.replace.assert_called_once() + client_mock.return_value.domains.get.return_value.nameservers.replace.assert_not_called() + + @patch("octodns_transip.TransIP") + def test_apply_nameservers_fail(self, client_mock): + + client_mock.return_value.domains.get.return_value.nameservers.replace.side_effect = [ + TransIPHTTPError(str(404), 404) + ] + + provider = TransipProvider( + "test", "unittest", self.bogus_key, strict_supports=False + ) + provider.ROOT_NS_TTL = 0 + + plan = provider.plan(make_expected()) + self.assertIsNotNone(plan) + with self.assertRaises(TransipSaveNameserverException): + provider.apply(plan) + @patch("octodns_transip.TransIP") def test_apply_unsupported(self, client_mock): # This triggers the if supported statement to give 100% code coverage domain_mock = Mock() client_mock.return_value.domains.get.return_value = domain_mock domain_mock.dns.list.return_value = [] + domain_mock.nameservers.list.return_value = [] provider = TransipProvider( "test", "unittest", self.bogus_key, strict_supports=False ) @@ -392,6 +714,7 @@ def test_apply_failure_on_not_found(self, client_mock): # but just in case. domain_mock = Mock() domain_mock.dns.list.return_value = [] + domain_mock.nameservers.list.return_value = [] client_mock.return_value.domains.get.side_effect = [ domain_mock, TransIPHTTPError("Not Found", 404), @@ -410,6 +733,7 @@ def test_apply_failure_on_error(self, client_mock): # Test unhappy flow. Trigger a unrecoverable error while saving domain_mock = Mock() domain_mock.dns.list.return_value = [] + domain_mock.nameservers.list.return_value = [] domain_mock.dns.replace.side_effect = [ TransIPHTTPError("Not Found", 500) ] @@ -420,7 +744,26 @@ def test_apply_failure_on_error(self, client_mock): plan = provider.plan(make_expected()) - with self.assertRaises(TransipException): + with self.assertRaises(TransipSaveRecordsException): + provider.apply(plan) + + @patch("octodns_transip.TransIP") + def test_apply_failure_on_error_nameserver(self, client_mock): + # Test unhappy flow. Trigger a unrecoverable error while saving + domain_mock = Mock() + domain_mock.dns.list.return_value = [] + domain_mock.nameservers.list.return_value = [] + domain_mock.nameservers.replace.side_effect = [ + TransIPHTTPError("Not Found", 500) + ] + client_mock.return_value.domains.get.return_value = domain_mock + provider = TransipProvider( + "test", "unittest", self.bogus_key, strict_supports=False + ) + + plan = provider.plan(make_expected()) + + with self.assertRaises(TransipSaveNameserverException): provider.apply(plan)