From be7c3d279e082dde2eec26d905164c8ab84546b7 Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Mon, 2 Jan 2023 10:12:59 -0500 Subject: [PATCH 1/5] WIP breakup of octodns.record file --- CHANGELOG.md | 6 + octodns/record/__init__.py | 1118 ++------------------------- octodns/record/base.py | 340 ++++++++ octodns/record/change.py | 55 ++ octodns/record/dynamic.py | 340 ++++++++ octodns/record/exception.py | 21 + octodns/record/geo.py | 101 +++ octodns/record/ipaddress.py | 78 ++ octodns/record/rr.py | 27 + octodns/record/target.py | 163 ++++ tests/test_octodns_provider_yaml.py | 4 +- tests/test_octodns_record.py | 14 +- 12 files changed, 1185 insertions(+), 1082 deletions(-) create mode 100644 octodns/record/base.py create mode 100644 octodns/record/change.py create mode 100644 octodns/record/dynamic.py create mode 100644 octodns/record/exception.py create mode 100644 octodns/record/ipaddress.py create mode 100644 octodns/record/rr.py create mode 100644 octodns/record/target.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0348d50..c89251f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ modules now. * Provider.strict_supports defaults to true, can be returned to the old behavior by setting strict_supports=False in your provider params. +* octodns.record has been broken up into multiple files/modules. Most of the + primary things that were available at that module path still will be, but if + you are importing things like idna_encode/decode that actually live elsewhere + from octodns.record you'll need to update and pull them from their actual + home. Classes beginning with _ are not exported from octodns.record any + longer as they were considered private/protected. #### Stuff diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index 2c18dba..516acf4 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -2,904 +2,57 @@ # # -from collections import defaultdict -from ipaddress import IPv4Address as _IPv4Address, IPv6Address as _IPv6Address -from logging import getLogger import re from fqdn import FQDN from ..equality import EqualityTupleMixin -from ..idna import IdnaError, idna_decode, idna_encode -from .geo import GeoCodes - - -class Change(EqualityTupleMixin): - def __init__(self, existing, new): - self.existing = existing - self.new = new - - @property - def record(self): - 'Returns new if we have one, existing otherwise' - return self.new or self.existing - - def _equality_tuple(self): - return (self.CLASS_ORDERING, self.record.name, self.record._type) - - -class Create(Change): - CLASS_ORDERING = 1 - - def __init__(self, new): - super().__init__(None, new) - - def __repr__(self, leader=''): - source = self.new.source.id if self.new.source else '' - return f'Create {self.new} ({source})' - - -class Update(Change): - CLASS_ORDERING = 2 - - # Leader is just to allow us to work around heven eating leading whitespace - # in our output. When we call this from the Manager.sync plan summary - # section we'll pass in a leader, otherwise we'll just let it default and - # do nothing - def __repr__(self, leader=''): - source = self.new.source.id if self.new.source else '' - return ( - f'Update\n{leader} {self.existing} ->\n' - f'{leader} {self.new} ({source})' - ) - - -class Delete(Change): - CLASS_ORDERING = 0 - - def __init__(self, existing): - super().__init__(existing, None) - - def __repr__(self, leader=''): - return f'Delete {self.existing}' - - -class RecordException(Exception): - pass - - -class RrParseError(RecordException): - def __init__(self, message='failed to parse string value as RR text'): - super().__init__(message) - - -class ValidationError(RecordException): - @classmethod - def build_message(cls, fqdn, reasons): - reasons = '\n - '.join(reasons) - return f'Invalid record {idna_decode(fqdn)}\n - {reasons}' - - def __init__(self, fqdn, reasons): - super().__init__(self.build_message(fqdn, reasons)) - self.fqdn = fqdn - self.reasons = reasons - - -class Rr(object): - ''' - Simple object intended to be used with Record.from_rrs to allow providers - that work with RFC formatted rdata to share centralized parsing/encoding - code - ''' - - def __init__(self, name, _type, ttl, rdata): - self.name = name - self._type = _type - self.ttl = ttl - self.rdata = rdata - - def __repr__(self): - return f'Rr<{self.name}, {self._type}, {self.ttl}, {self.rdata}' - - -class Record(EqualityTupleMixin): - log = getLogger('Record') - - _CLASSES = {} - - @classmethod - def register_type(cls, _class, _type=None): - if _type is None: - _type = _class._type - existing = cls._CLASSES.get(_type) - if existing: - module = existing.__module__ - name = existing.__name__ - msg = f'Type "{_type}" already registered by {module}.{name}' - raise RecordException(msg) - cls._CLASSES[_type] = _class - - @classmethod - def registered_types(cls): - return cls._CLASSES - - @classmethod - def new(cls, zone, name, data, source=None, lenient=False): - reasons = [] - try: - name = idna_encode(str(name)) - except IdnaError as e: - # convert the error into a reason - reasons.append(str(e)) - name = str(name) - fqdn = f'{name}.{zone.name}' if name else zone.name - try: - _type = data['type'] - except KeyError: - raise Exception(f'Invalid record {idna_decode(fqdn)}, missing type') - try: - _class = cls._CLASSES[_type] - except KeyError: - raise Exception(f'Unknown record type: "{_type}"') - reasons.extend(_class.validate(name, fqdn, data)) - try: - lenient |= data['octodns']['lenient'] - except KeyError: - pass - if reasons: - if lenient: - cls.log.warning(ValidationError.build_message(fqdn, reasons)) - else: - raise ValidationError(fqdn, reasons) - return _class(zone, name, data, source=source) - - @classmethod - def validate(cls, name, fqdn, data): - reasons = [] - if name == '@': - reasons.append('invalid name "@", use "" instead') - n = len(fqdn) - if n > 253: - reasons.append( - f'invalid fqdn, "{idna_decode(fqdn)}" is too long at {n} ' - 'chars, max is 253' - ) - for label in name.split('.'): - n = len(label) - if n > 63: - reasons.append( - f'invalid label, "{label}" is too long at {n}' - ' chars, max is 63' - ) - # TODO: look at the idna lib for a lot more potential validations... - try: - ttl = int(data['ttl']) - if ttl < 0: - reasons.append('invalid ttl') - except KeyError: - reasons.append('missing ttl') - try: - if data['octodns']['healthcheck']['protocol'] not in ( - 'HTTP', - 'HTTPS', - 'TCP', - ): - reasons.append('invalid healthcheck protocol') - except KeyError: - pass - return reasons - - @classmethod - def from_rrs(cls, zone, rrs, lenient=False): - # group records by name & type so that multiple rdatas can be combined - # into a single record when needed - grouped = defaultdict(list) - for rr in rrs: - grouped[(rr.name, rr._type)].append(rr) - - records = [] - # walk the grouped rrs converting each one to data and then create a - # record with that data - for _, rrs in sorted(grouped.items()): - rr = rrs[0] - name = zone.hostname_from_fqdn(rr.name) - _class = cls._CLASSES[rr._type] - data = _class.data_from_rrs(rrs) - record = Record.new(zone, name, data, lenient=lenient) - records.append(record) - - return records - - def __init__(self, zone, name, data, source=None): - self.zone = zone - if name: - # internally everything is idna - self.name = idna_encode(str(name)) - # we'll keep a decoded version around for logs and errors - self.decoded_name = idna_decode(self.name) - else: - self.name = self.decoded_name = name - self.log.debug( - '__init__: zone.name=%s, type=%11s, name=%s', - zone.decoded_name, - self.__class__.__name__, - self.decoded_name, - ) - self.source = source - self.ttl = int(data['ttl']) - - self._octodns = data.get('octodns', {}) - - def _data(self): - return {'ttl': self.ttl} - - @property - def data(self): - return self._data() - - @property - def fqdn(self): - # TODO: these should be calculated and set in __init__ rather than on - # each use - if self.name: - return f'{self.name}.{self.zone.name}' - return self.zone.name - - @property - def decoded_fqdn(self): - if self.decoded_name: - return f'{self.decoded_name}.{self.zone.decoded_name}' - return self.zone.decoded_name - - @property - def ignored(self): - return self._octodns.get('ignored', False) - - @property - def excluded(self): - return self._octodns.get('excluded', []) - - @property - def included(self): - return self._octodns.get('included', []) - - def healthcheck_host(self, value=None): - healthcheck = self._octodns.get('healthcheck', {}) - if healthcheck.get('protocol', None) == 'TCP': - return None - return healthcheck.get('host', self.fqdn[:-1]) or value - - @property - def healthcheck_path(self): - healthcheck = self._octodns.get('healthcheck', {}) - if healthcheck.get('protocol', None) == 'TCP': - return None - try: - return healthcheck['path'] - except KeyError: - return '/_dns' - - @property - def healthcheck_protocol(self): - try: - return self._octodns['healthcheck']['protocol'] - except KeyError: - return 'HTTPS' - - @property - def healthcheck_port(self): - try: - return int(self._octodns['healthcheck']['port']) - except KeyError: - return 443 - - def changes(self, other, target): - # We're assuming we have the same name and type if we're being compared - if self.ttl != other.ttl: - return Update(self, other) - - def copy(self, zone=None): - data = self.data - data['type'] = self._type - data['octodns'] = self._octodns - - return Record.new( - zone if zone else self.zone, - self.name, - data, - self.source, - lenient=True, - ) - - # NOTE: we're using __hash__ and ordering methods that consider Records - # equivalent if they have the same name & _type. Values are ignored. This - # is useful when computing diffs/changes. - - def __hash__(self): - return f'{self.name}:{self._type}'.__hash__() - - def _equality_tuple(self): - return (self.name, self._type) - - def __repr__(self): - # Make sure this is always overridden - raise NotImplementedError('Abstract base class, __repr__ required') - - -class GeoValue(EqualityTupleMixin): - geo_re = re.compile( - r'^(?P\w\w)(-(?P\w\w)' - r'(-(?P\w\w))?)?$' - ) - - @classmethod - def _validate_geo(cls, code): - reasons = [] - match = cls.geo_re.match(code) - if not match: - reasons.append(f'invalid geo "{code}"') - return reasons - - def __init__(self, geo, values): - self.code = geo - match = self.geo_re.match(geo) - self.continent_code = match.group('continent_code') - self.country_code = match.group('country_code') - self.subdivision_code = match.group('subdivision_code') - self.values = sorted(values) - - @property - def parents(self): - bits = self.code.split('-')[:-1] - while bits: - yield '-'.join(bits) - bits.pop() - - def _equality_tuple(self): - return ( - self.continent_code, - self.country_code, - self.subdivision_code, - self.values, - ) - - def __repr__(self): - return ( - f"'Geo {self.continent_code} {self.country_code} " - "{self.subdivision_code} {self.values}'" - ) - - -class ValuesMixin(object): - @classmethod - def validate(cls, name, fqdn, data): - reasons = super().validate(name, fqdn, data) - - values = data.get('values', data.get('value', [])) - - reasons.extend(cls._value_type.validate(values, cls._type)) - - return reasons - - @classmethod - def data_from_rrs(cls, rrs): - # type and TTL come from the first rr - rr = rrs[0] - # values come from parsing the rdata portion of all rrs - values = [cls._value_type.parse_rdata_text(rr.rdata) for rr in rrs] - return {'ttl': rr.ttl, 'type': rr._type, 'values': values} - - def __init__(self, zone, name, data, source=None): - super().__init__(zone, name, data, source=source) - try: - values = data['values'] - except KeyError: - values = [data['value']] - self.values = sorted(self._value_type.process(values)) - - def changes(self, other, target): - if self.values != other.values: - return Update(self, other) - return super().changes(other, target) - - def _data(self): - ret = super()._data() - if len(self.values) > 1: - values = [getattr(v, 'data', v) for v in self.values if v] - if len(values) > 1: - ret['values'] = values - elif len(values) == 1: - ret['value'] = values[0] - elif len(self.values) == 1: - v = self.values[0] - if v: - ret['value'] = getattr(v, 'data', v) - - return ret - - @property - def rrs(self): - return ( - self.fqdn, - self.ttl, - self._type, - [v.rdata_text for v in self.values], - ) - - def __repr__(self): - values = "', '".join([str(v) for v in self.values]) - klass = self.__class__.__name__ - return f"<{klass} {self._type} {self.ttl}, {self.decoded_fqdn}, ['{values}']>" - - -class _GeoMixin(ValuesMixin): - ''' - Adds GeoDNS support to a record. - - Must be included before `Record`. - ''' - - @classmethod - def validate(cls, name, fqdn, data): - reasons = super().validate(name, fqdn, data) - try: - geo = dict(data['geo']) - for code, values in geo.items(): - reasons.extend(GeoValue._validate_geo(code)) - reasons.extend(cls._value_type.validate(values, cls._type)) - except KeyError: - pass - return reasons - - def __init__(self, zone, name, data, *args, **kwargs): - super().__init__(zone, name, data, *args, **kwargs) - try: - self.geo = dict(data['geo']) - except KeyError: - self.geo = {} - for code, values in self.geo.items(): - self.geo[code] = GeoValue(code, values) - - def _data(self): - ret = super()._data() - if self.geo: - geo = {} - for code, value in self.geo.items(): - geo[code] = value.values - ret['geo'] = geo - return ret - - def changes(self, other, target): - if target.SUPPORTS_GEO: - if self.geo != other.geo: - return Update(self, other) - return super().changes(other, target) - - def __repr__(self): - if self.geo: - klass = self.__class__.__name__ - return ( - f'<{klass} {self._type} {self.ttl}, {self.decoded_fqdn}, ' - f'{self.values}, {self.geo}>' - ) - return super().__repr__() - - -class ValueMixin(object): - @classmethod - def validate(cls, name, fqdn, data): - reasons = super().validate(name, fqdn, data) - reasons.extend( - cls._value_type.validate(data.get('value', None), cls._type) - ) - return reasons - - @classmethod - def data_from_rrs(cls, rrs): - # single value, so single rr only... - rr = rrs[0] - return { - 'ttl': rr.ttl, - 'type': rr._type, - 'value': cls._value_type.parse_rdata_text(rr.rdata), - } - - def __init__(self, zone, name, data, source=None): - super().__init__(zone, name, data, source=source) - self.value = self._value_type.process(data['value']) - - def changes(self, other, target): - if self.value != other.value: - return Update(self, other) - return super().changes(other, target) - - def _data(self): - ret = super()._data() - if self.value: - ret['value'] = getattr(self.value, 'data', self.value) - return ret - - @property - def rrs(self): - return self.fqdn, self.ttl, self._type, [self.value.rdata_text] - - def __repr__(self): - klass = self.__class__.__name__ - return f'<{klass} {self._type} {self.ttl}, {self.decoded_fqdn}, {self.value}>' - - -class _DynamicPool(object): - log = getLogger('_DynamicPool') - - def __init__(self, _id, data, value_type): - self._id = _id - - values = [ - { - 'value': value_type(d['value']), - 'weight': d.get('weight', 1), - 'status': d.get('status', 'obey'), - } - for d in data['values'] - ] - values.sort(key=lambda d: d['value']) - - # normalize weight of a single-value pool - if len(values) == 1: - weight = data['values'][0].get('weight', 1) - if weight != 1: - self.log.warning( - 'Using weight=1 instead of %s for single-value pool %s', - weight, - _id, - ) - values[0]['weight'] = 1 - - fallback = data.get('fallback', None) - self.data = { - 'fallback': fallback if fallback != 'default' else None, - 'values': values, - } - - def _data(self): - return self.data - - def __eq__(self, other): - if not isinstance(other, _DynamicPool): - return False - return self.data == other.data - - def __ne__(self, other): - return not self.__eq__(other) - - def __repr__(self): - return f'{self.data}' - - -class _DynamicRule(object): - def __init__(self, i, data): - self.i = i - - self.data = {} - try: - self.data['pool'] = data['pool'] - except KeyError: - pass - try: - self.data['geos'] = sorted(data['geos']) - except KeyError: - pass - - def _data(self): - return self.data - - def __eq__(self, other): - if not isinstance(other, _DynamicRule): - return False - return self.data == other.data - - def __ne__(self, other): - return not self.__eq__(other) - - def __repr__(self): - return f'{self.data}' - - -class _Dynamic(object): - def __init__(self, pools, rules): - self.pools = pools - self.rules = rules - - def _data(self): - pools = {} - for _id, pool in self.pools.items(): - pools[_id] = pool._data() - rules = [] - for rule in self.rules: - rules.append(rule._data()) - return {'pools': pools, 'rules': rules} - - def __eq__(self, other): - if not isinstance(other, _Dynamic): - return False - ret = self.pools == other.pools and self.rules == other.rules - return ret - - def __ne__(self, other): - return not self.__eq__(other) - - def __repr__(self): - return f'{self.pools}, {self.rules}' - - -class _DynamicMixin(object): - geo_re = re.compile( - r'^(?P\w\w)(-(?P\w\w)' - r'(-(?P\w\w))?)?$' - ) - - @classmethod - def validate(cls, name, fqdn, data): - reasons = super().validate(name, fqdn, data) - - if 'dynamic' not in data: - return reasons - elif 'geo' in data: - reasons.append('"dynamic" record with "geo" content') - - try: - pools = data['dynamic']['pools'] - except KeyError: - pools = {} - - pools_exist = set() - pools_seen = set() - pools_seen_as_fallback = set() - if not isinstance(pools, dict): - reasons.append('pools must be a dict') - elif not pools: - reasons.append('missing pools') - else: - for _id, pool in sorted(pools.items()): - if not isinstance(pool, dict): - reasons.append(f'pool "{_id}" must be a dict') - continue - try: - values = pool['values'] - except KeyError: - reasons.append(f'pool "{_id}" is missing values') - continue - - pools_exist.add(_id) - - for i, value in enumerate(values): - value_num = i + 1 - try: - weight = value['weight'] - weight = int(weight) - if weight < 1 or weight > 100: - reasons.append( - f'invalid weight "{weight}" in ' - f'pool "{_id}" value {value_num}' - ) - except KeyError: - pass - except ValueError: - reasons.append( - f'invalid weight "{weight}" in ' - f'pool "{_id}" value {value_num}' - ) - - try: - status = value['status'] - if status not in ['up', 'down', 'obey']: - reasons.append( - f'invalid status "{status}" in ' - f'pool "{_id}" value {value_num}' - ) - except KeyError: - pass - - try: - value = value['value'] - reasons.extend( - cls._value_type.validate(value, cls._type) - ) - except KeyError: - reasons.append( - f'missing value in pool "{_id}" ' - f'value {value_num}' - ) - - if len(values) == 1 and values[0].get('weight', 1) != 1: - reasons.append( - f'pool "{_id}" has single value with weight!=1' - ) - - fallback = pool.get('fallback', None) - if fallback is not None: - if fallback in pools: - pools_seen_as_fallback.add(fallback) - else: - reasons.append( - f'undefined fallback "{fallback}" ' - f'for pool "{_id}"' - ) - - # Check for loops - fallback = pools[_id].get('fallback', None) - seen = [_id, fallback] - while fallback is not None: - # See if there's a next fallback - fallback = pools.get(fallback, {}).get('fallback', None) - if fallback in seen: - loop = ' -> '.join(seen) - reasons.append(f'loop in pool fallbacks: {loop}') - # exit the loop - break - seen.append(fallback) - - try: - rules = data['dynamic']['rules'] - except KeyError: - rules = [] - - if not isinstance(rules, (list, tuple)): - reasons.append('rules must be a list') - elif not rules: - reasons.append('missing rules') - else: - seen_default = False - - for i, rule in enumerate(rules): - rule_num = i + 1 - try: - pool = rule['pool'] - except KeyError: - reasons.append(f'rule {rule_num} missing pool') - continue - - try: - geos = rule['geos'] - except KeyError: - geos = [] - - if not isinstance(pool, str): - reasons.append(f'rule {rule_num} invalid pool "{pool}"') - else: - if pool not in pools: - reasons.append( - f'rule {rule_num} undefined pool ' f'"{pool}"' - ) - elif pool in pools_seen and geos: - reasons.append( - f'rule {rule_num} invalid, target ' - f'pool "{pool}" reused' - ) - pools_seen.add(pool) - - if not geos: - if seen_default: - reasons.append(f'rule {rule_num} duplicate default') - seen_default = True - - if not isinstance(geos, (list, tuple)): - reasons.append(f'rule {rule_num} geos must be a list') - else: - for geo in geos: - reasons.extend( - GeoCodes.validate(geo, f'rule {rule_num} ') - ) - - unused = pools_exist - pools_seen - pools_seen_as_fallback - if unused: - unused = '", "'.join(sorted(unused)) - reasons.append(f'unused pools: "{unused}"') - - return reasons - - def __init__(self, zone, name, data, *args, **kwargs): - super().__init__(zone, name, data, *args, **kwargs) - - self.dynamic = {} - - if 'dynamic' not in data: - return - - # pools - try: - pools = dict(data['dynamic']['pools']) - except: - pools = {} - - for _id, pool in sorted(pools.items()): - pools[_id] = _DynamicPool(_id, pool, self._value_type) - - # rules - try: - rules = list(data['dynamic']['rules']) - except: - rules = [] - - parsed = [] - for i, rule in enumerate(rules): - parsed.append(_DynamicRule(i, rule)) - - # dynamic - self.dynamic = _Dynamic(pools, parsed) - - def _data(self): - ret = super()._data() - if self.dynamic: - ret['dynamic'] = self.dynamic._data() - return ret - - def changes(self, other, target): - if target.SUPPORTS_DYNAMIC: - if self.dynamic != other.dynamic: - return Update(self, other) - return super().changes(other, target) - - def __repr__(self): - # TODO: improve this whole thing, we need multi-line... - if self.dynamic: - # TODO: this hack can't going to cut it, as part of said - # improvements the value types should deal with serializing their - # value - try: - values = self.values - except AttributeError: - values = self.value - - klass = self.__class__.__name__ - return ( - f'<{klass} {self._type} {self.ttl}, {self.decoded_fqdn}, ' - f'{values}, {self.dynamic}>' - ) - return super().__repr__() - - -class _TargetValue(str): - @classmethod - def parse_rdata_text(self, value): - return value - - @classmethod - def validate(cls, data, _type): - reasons = [] - if data == '': - reasons.append('empty value') - elif not data: - reasons.append('missing value') - else: - data = idna_encode(data) - if not FQDN(str(data), allow_underscores=True).is_valid: - reasons.append(f'{_type} value "{data}" is not a valid FQDN') - elif not data.endswith('.'): - reasons.append(f'{_type} value "{data}" missing trailing .') - return reasons - - @classmethod - def process(cls, value): - if value: - return cls(value) - return None - - def __new__(cls, v): - v = idna_encode(v) - return super().__new__(cls, v) - - @property - def rdata_text(self): - return self - - -class CnameValue(_TargetValue): - pass - - -class DnameValue(_TargetValue): - pass +from ..idna import idna_encode +from .geo import GeoCodes, GeoValue + +from .base import Record, ValueMixin, ValuesMixin +from .change import Create, Delete, Update +from .exception import RecordException, ValidationError +from .rr import Rr, RrParseError +from .target import ( + AliasRecord, + AliasValue, + CnameRecord, + CnameValue, + DnameRecord, + DnameValue, + NsValue, + NsRecord, + PtrValue, + PtrRecord, +) +from .ipaddress import ARecord, AaaaRecord, Ipv4Address, Ipv6Address + +# quell warnings +ARecord +AaaaRecord +AliasRecord +AliasValue +CnameRecord +CnameValue +Create +Delete +DnameRecord +DnameValue +GeoCodes +GeoValue +Ipv4Address +Ipv6Address +NsRecord +NsValue +PtrRecord +PtrValue +RecordException +Rr +Update +ValidationError +ValueMixin +ValuesMixin class DsValue(EqualityTupleMixin, dict): @@ -1031,95 +184,6 @@ class DsRecord(ValuesMixin, Record): Record.register_type(DsRecord) -class _IpAddress(str): - @classmethod - def parse_rdata_text(cls, value): - return value - - @classmethod - def validate(cls, data, _type): - if not isinstance(data, (list, tuple)): - data = (data,) - if len(data) == 0: - return ['missing value(s)'] - reasons = [] - for value in data: - if value == '': - reasons.append('empty value') - elif value is None: - reasons.append('missing value(s)') - else: - try: - cls._address_type(str(value)) - except Exception: - addr_name = cls._address_name - reasons.append(f'invalid {addr_name} address "{value}"') - return reasons - - @classmethod - def process(cls, values): - # Translating None into '' so that the list will be sortable in - # python3, get everything to str first - values = [v if v is not None else '' for v in values] - # Now round trip all non-'' through the address type and back to a str - # to normalize the address representation. - return [cls(v) if v != '' else '' for v in values] - - def __new__(cls, v): - v = str(cls._address_type(v)) - return super().__new__(cls, v) - - @property - def rdata_text(self): - return self - - -class Ipv4Address(_IpAddress): - _address_type = _IPv4Address - _address_name = 'IPv4' - - -class ARecord(_DynamicMixin, _GeoMixin, Record): - _type = 'A' - _value_type = Ipv4Address - - -Record.register_type(ARecord) - - -class Ipv6Address(_IpAddress): - _address_type = _IPv6Address - _address_name = 'IPv6' - - -class AaaaRecord(_DynamicMixin, _GeoMixin, Record): - _type = 'AAAA' - _value_type = Ipv6Address - - -Record.register_type(AaaaRecord) - - -class AliasValue(_TargetValue): - pass - - -class AliasRecord(ValueMixin, Record): - _type = 'ALIAS' - _value_type = AliasValue - - @classmethod - def validate(cls, name, fqdn, data): - reasons = [] - if name != '': - reasons.append('non-root ALIAS not allowed') - reasons.extend(super().validate(name, fqdn, data)) - return reasons - - -Record.register_type(AliasRecord) - - class CaaValue(EqualityTupleMixin, dict): # https://tools.ietf.org/html/rfc6844#page-5 @@ -1214,30 +278,6 @@ class CaaRecord(ValuesMixin, Record): Record.register_type(CaaRecord) -class CnameRecord(_DynamicMixin, ValueMixin, Record): - _type = 'CNAME' - _value_type = CnameValue - - @classmethod - def validate(cls, name, fqdn, data): - reasons = [] - if name == '': - reasons.append('root CNAME not allowed') - reasons.extend(super().validate(name, fqdn, data)) - return reasons - - -Record.register_type(CnameRecord) - - -class DnameRecord(_DynamicMixin, ValueMixin, Record): - _type = 'DNAME' - _value_type = DnameValue - - -Record.register_type(DnameRecord) - - class LocValue(EqualityTupleMixin, dict): # TODO: this does not really match the RFC, but it's stuck using the details # of how the type was impelemented. Would be nice to rework things to match @@ -1864,72 +904,6 @@ class NaptrRecord(ValuesMixin, Record): Record.register_type(NaptrRecord) -# much like _TargetValue, but geared towards multiple values -class _TargetsValue(str): - @classmethod - def parse_rdata_text(cls, value): - return value - - @classmethod - def validate(cls, data, _type): - if not data: - return ['missing value(s)'] - elif not isinstance(data, (list, tuple)): - data = (data,) - reasons = [] - for value in data: - value = idna_encode(value) - if not FQDN(value, allow_underscores=True).is_valid: - reasons.append( - f'Invalid {_type} value "{value}" is not a valid FQDN.' - ) - elif not value.endswith('.'): - reasons.append(f'{_type} value "{value}" missing trailing .') - return reasons - - @classmethod - def process(cls, values): - return [cls(v) for v in values] - - def __new__(cls, v): - v = idna_encode(v) - return super().__new__(cls, v) - - @property - def rdata_text(self): - return self - - -class _NsValue(_TargetsValue): - pass - - -class NsRecord(ValuesMixin, Record): - _type = 'NS' - _value_type = _NsValue - - -Record.register_type(NsRecord) - - -class PtrValue(_TargetsValue): - pass - - -class PtrRecord(ValuesMixin, Record): - _type = 'PTR' - _value_type = PtrValue - - # This is for backward compatibility with providers that don't support - # multi-value PTR records. - @property - def value(self): - return self.values[0] - - -Record.register_type(PtrRecord) - - class SshfpValue(EqualityTupleMixin, dict): VALID_ALGORITHMS = (1, 2, 3, 4) VALID_FINGERPRINT_TYPES = (1, 2) diff --git a/octodns/record/base.py b/octodns/record/base.py new file mode 100644 index 0000000..e9f9599 --- /dev/null +++ b/octodns/record/base.py @@ -0,0 +1,340 @@ +# +# +# + +from collections import defaultdict +from logging import getLogger + +from ..equality import EqualityTupleMixin +from ..idna import IdnaError, idna_decode, idna_encode +from .change import Update +from .exception import RecordException, ValidationError + + +class Record(EqualityTupleMixin): + log = getLogger('Record') + + _CLASSES = {} + + @classmethod + def register_type(cls, _class, _type=None): + if _type is None: + _type = _class._type + existing = cls._CLASSES.get(_type) + if existing: + module = existing.__module__ + name = existing.__name__ + msg = f'Type "{_type}" already registered by {module}.{name}' + raise RecordException(msg) + cls._CLASSES[_type] = _class + + @classmethod + def registered_types(cls): + return cls._CLASSES + + @classmethod + def new(cls, zone, name, data, source=None, lenient=False): + reasons = [] + try: + name = idna_encode(str(name)) + except IdnaError as e: + # convert the error into a reason + reasons.append(str(e)) + name = str(name) + fqdn = f'{name}.{zone.name}' if name else zone.name + try: + _type = data['type'] + except KeyError: + raise Exception(f'Invalid record {idna_decode(fqdn)}, missing type') + try: + _class = cls._CLASSES[_type] + except KeyError: + raise Exception(f'Unknown record type: "{_type}"') + reasons.extend(_class.validate(name, fqdn, data)) + try: + lenient |= data['octodns']['lenient'] + except KeyError: + pass + if reasons: + if lenient: + cls.log.warning(ValidationError.build_message(fqdn, reasons)) + else: + raise ValidationError(fqdn, reasons) + return _class(zone, name, data, source=source) + + @classmethod + def validate(cls, name, fqdn, data): + reasons = [] + if name == '@': + reasons.append('invalid name "@", use "" instead') + n = len(fqdn) + if n > 253: + reasons.append( + f'invalid fqdn, "{idna_decode(fqdn)}" is too long at {n} ' + 'chars, max is 253' + ) + for label in name.split('.'): + n = len(label) + if n > 63: + reasons.append( + f'invalid label, "{label}" is too long at {n}' + ' chars, max is 63' + ) + # TODO: look at the idna lib for a lot more potential validations... + try: + ttl = int(data['ttl']) + if ttl < 0: + reasons.append('invalid ttl') + except KeyError: + reasons.append('missing ttl') + try: + if data['octodns']['healthcheck']['protocol'] not in ( + 'HTTP', + 'HTTPS', + 'TCP', + ): + reasons.append('invalid healthcheck protocol') + except KeyError: + pass + return reasons + + @classmethod + def from_rrs(cls, zone, rrs, lenient=False): + # group records by name & type so that multiple rdatas can be combined + # into a single record when needed + grouped = defaultdict(list) + for rr in rrs: + grouped[(rr.name, rr._type)].append(rr) + + records = [] + # walk the grouped rrs converting each one to data and then create a + # record with that data + for _, rrs in sorted(grouped.items()): + rr = rrs[0] + name = zone.hostname_from_fqdn(rr.name) + _class = cls._CLASSES[rr._type] + data = _class.data_from_rrs(rrs) + record = Record.new(zone, name, data, lenient=lenient) + records.append(record) + + return records + + def __init__(self, zone, name, data, source=None): + self.zone = zone + if name: + # internally everything is idna + self.name = idna_encode(str(name)) + # we'll keep a decoded version around for logs and errors + self.decoded_name = idna_decode(self.name) + else: + self.name = self.decoded_name = name + self.log.debug( + '__init__: zone.name=%s, type=%11s, name=%s', + zone.decoded_name, + self.__class__.__name__, + self.decoded_name, + ) + self.source = source + self.ttl = int(data['ttl']) + + self._octodns = data.get('octodns', {}) + + def _data(self): + return {'ttl': self.ttl} + + @property + def data(self): + return self._data() + + @property + def fqdn(self): + # TODO: these should be calculated and set in __init__ rather than on + # each use + if self.name: + return f'{self.name}.{self.zone.name}' + return self.zone.name + + @property + def decoded_fqdn(self): + if self.decoded_name: + return f'{self.decoded_name}.{self.zone.decoded_name}' + return self.zone.decoded_name + + @property + def ignored(self): + return self._octodns.get('ignored', False) + + @property + def excluded(self): + return self._octodns.get('excluded', []) + + @property + def included(self): + return self._octodns.get('included', []) + + def healthcheck_host(self, value=None): + healthcheck = self._octodns.get('healthcheck', {}) + if healthcheck.get('protocol', None) == 'TCP': + return None + return healthcheck.get('host', self.fqdn[:-1]) or value + + @property + def healthcheck_path(self): + healthcheck = self._octodns.get('healthcheck', {}) + if healthcheck.get('protocol', None) == 'TCP': + return None + try: + return healthcheck['path'] + except KeyError: + return '/_dns' + + @property + def healthcheck_protocol(self): + try: + return self._octodns['healthcheck']['protocol'] + except KeyError: + return 'HTTPS' + + @property + def healthcheck_port(self): + try: + return int(self._octodns['healthcheck']['port']) + except KeyError: + return 443 + + def changes(self, other, target): + # We're assuming we have the same name and type if we're being compared + if self.ttl != other.ttl: + return Update(self, other) + + def copy(self, zone=None): + data = self.data + data['type'] = self._type + data['octodns'] = self._octodns + + return Record.new( + zone if zone else self.zone, + self.name, + data, + self.source, + lenient=True, + ) + + # NOTE: we're using __hash__ and ordering methods that consider Records + # equivalent if they have the same name & _type. Values are ignored. This + # is useful when computing diffs/changes. + + def __hash__(self): + return f'{self.name}:{self._type}'.__hash__() + + def _equality_tuple(self): + return (self.name, self._type) + + def __repr__(self): + # Make sure this is always overridden + raise NotImplementedError('Abstract base class, __repr__ required') + + +class ValuesMixin(object): + @classmethod + def validate(cls, name, fqdn, data): + reasons = super().validate(name, fqdn, data) + + values = data.get('values', data.get('value', [])) + + reasons.extend(cls._value_type.validate(values, cls._type)) + + return reasons + + @classmethod + def data_from_rrs(cls, rrs): + # type and TTL come from the first rr + rr = rrs[0] + # values come from parsing the rdata portion of all rrs + values = [cls._value_type.parse_rdata_text(rr.rdata) for rr in rrs] + return {'ttl': rr.ttl, 'type': rr._type, 'values': values} + + def __init__(self, zone, name, data, source=None): + super().__init__(zone, name, data, source=source) + try: + values = data['values'] + except KeyError: + values = [data['value']] + self.values = sorted(self._value_type.process(values)) + + def changes(self, other, target): + if self.values != other.values: + return Update(self, other) + return super().changes(other, target) + + def _data(self): + ret = super()._data() + if len(self.values) > 1: + values = [getattr(v, 'data', v) for v in self.values if v] + if len(values) > 1: + ret['values'] = values + elif len(values) == 1: + ret['value'] = values[0] + elif len(self.values) == 1: + v = self.values[0] + if v: + ret['value'] = getattr(v, 'data', v) + + return ret + + @property + def rrs(self): + return ( + self.fqdn, + self.ttl, + self._type, + [v.rdata_text for v in self.values], + ) + + def __repr__(self): + values = "', '".join([str(v) for v in self.values]) + klass = self.__class__.__name__ + return f"<{klass} {self._type} {self.ttl}, {self.decoded_fqdn}, ['{values}']>" + + +class ValueMixin(object): + @classmethod + def validate(cls, name, fqdn, data): + reasons = super().validate(name, fqdn, data) + reasons.extend( + cls._value_type.validate(data.get('value', None), cls._type) + ) + return reasons + + @classmethod + def data_from_rrs(cls, rrs): + # single value, so single rr only... + rr = rrs[0] + return { + 'ttl': rr.ttl, + 'type': rr._type, + 'value': cls._value_type.parse_rdata_text(rr.rdata), + } + + def __init__(self, zone, name, data, source=None): + super().__init__(zone, name, data, source=source) + self.value = self._value_type.process(data['value']) + + def changes(self, other, target): + if self.value != other.value: + return Update(self, other) + return super().changes(other, target) + + def _data(self): + ret = super()._data() + if self.value: + ret['value'] = getattr(self.value, 'data', self.value) + return ret + + @property + def rrs(self): + return self.fqdn, self.ttl, self._type, [self.value.rdata_text] + + def __repr__(self): + klass = self.__class__.__name__ + return f'<{klass} {self._type} {self.ttl}, {self.decoded_fqdn}, {self.value}>' diff --git a/octodns/record/change.py b/octodns/record/change.py new file mode 100644 index 0000000..59b4810 --- /dev/null +++ b/octodns/record/change.py @@ -0,0 +1,55 @@ +# +# +# + +from ..equality import EqualityTupleMixin + + +class Change(EqualityTupleMixin): + def __init__(self, existing, new): + self.existing = existing + self.new = new + + @property + def record(self): + 'Returns new if we have one, existing otherwise' + return self.new or self.existing + + def _equality_tuple(self): + return (self.CLASS_ORDERING, self.record.name, self.record._type) + + +class Create(Change): + CLASS_ORDERING = 1 + + def __init__(self, new): + super().__init__(None, new) + + def __repr__(self, leader=''): + source = self.new.source.id if self.new.source else '' + return f'Create {self.new} ({source})' + + +class Update(Change): + CLASS_ORDERING = 2 + + # Leader is just to allow us to work around heven eating leading whitespace + # in our output. When we call this from the Manager.sync plan summary + # section we'll pass in a leader, otherwise we'll just let it default and + # do nothing + def __repr__(self, leader=''): + source = self.new.source.id if self.new.source else '' + return ( + f'Update\n{leader} {self.existing} ->\n' + f'{leader} {self.new} ({source})' + ) + + +class Delete(Change): + CLASS_ORDERING = 0 + + def __init__(self, existing): + super().__init__(existing, None) + + def __repr__(self, leader=''): + return f'Delete {self.existing}' diff --git a/octodns/record/dynamic.py b/octodns/record/dynamic.py new file mode 100644 index 0000000..a104212 --- /dev/null +++ b/octodns/record/dynamic.py @@ -0,0 +1,340 @@ +# +# +# + +from logging import getLogger +import re + +from .change import Update +from .geo import GeoCodes + + +class _DynamicPool(object): + log = getLogger('_DynamicPool') + + def __init__(self, _id, data, value_type): + self._id = _id + + values = [ + { + 'value': value_type(d['value']), + 'weight': d.get('weight', 1), + 'status': d.get('status', 'obey'), + } + for d in data['values'] + ] + values.sort(key=lambda d: d['value']) + + # normalize weight of a single-value pool + if len(values) == 1: + weight = data['values'][0].get('weight', 1) + if weight != 1: + self.log.warning( + 'Using weight=1 instead of %s for single-value pool %s', + weight, + _id, + ) + values[0]['weight'] = 1 + + fallback = data.get('fallback', None) + self.data = { + 'fallback': fallback if fallback != 'default' else None, + 'values': values, + } + + def _data(self): + return self.data + + def __eq__(self, other): + if not isinstance(other, _DynamicPool): + return False + return self.data == other.data + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return f'{self.data}' + + +class _DynamicRule(object): + def __init__(self, i, data): + self.i = i + + self.data = {} + try: + self.data['pool'] = data['pool'] + except KeyError: + pass + try: + self.data['geos'] = sorted(data['geos']) + except KeyError: + pass + + def _data(self): + return self.data + + def __eq__(self, other): + if not isinstance(other, _DynamicRule): + return False + return self.data == other.data + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return f'{self.data}' + + +class _Dynamic(object): + def __init__(self, pools, rules): + self.pools = pools + self.rules = rules + + def _data(self): + pools = {} + for _id, pool in self.pools.items(): + pools[_id] = pool._data() + rules = [] + for rule in self.rules: + rules.append(rule._data()) + return {'pools': pools, 'rules': rules} + + def __eq__(self, other): + if not isinstance(other, _Dynamic): + return False + ret = self.pools == other.pools and self.rules == other.rules + return ret + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return f'{self.pools}, {self.rules}' + + +class _DynamicMixin(object): + geo_re = re.compile( + r'^(?P\w\w)(-(?P\w\w)' + r'(-(?P\w\w))?)?$' + ) + + @classmethod + def validate(cls, name, fqdn, data): + reasons = super().validate(name, fqdn, data) + + if 'dynamic' not in data: + return reasons + elif 'geo' in data: + reasons.append('"dynamic" record with "geo" content') + + try: + pools = data['dynamic']['pools'] + except KeyError: + pools = {} + + pools_exist = set() + pools_seen = set() + pools_seen_as_fallback = set() + if not isinstance(pools, dict): + reasons.append('pools must be a dict') + elif not pools: + reasons.append('missing pools') + else: + for _id, pool in sorted(pools.items()): + if not isinstance(pool, dict): + reasons.append(f'pool "{_id}" must be a dict') + continue + try: + values = pool['values'] + except KeyError: + reasons.append(f'pool "{_id}" is missing values') + continue + + pools_exist.add(_id) + + for i, value in enumerate(values): + value_num = i + 1 + try: + weight = value['weight'] + weight = int(weight) + if weight < 1 or weight > 100: + reasons.append( + f'invalid weight "{weight}" in ' + f'pool "{_id}" value {value_num}' + ) + except KeyError: + pass + except ValueError: + reasons.append( + f'invalid weight "{weight}" in ' + f'pool "{_id}" value {value_num}' + ) + + try: + status = value['status'] + if status not in ['up', 'down', 'obey']: + reasons.append( + f'invalid status "{status}" in ' + f'pool "{_id}" value {value_num}' + ) + except KeyError: + pass + + try: + value = value['value'] + reasons.extend( + cls._value_type.validate(value, cls._type) + ) + except KeyError: + reasons.append( + f'missing value in pool "{_id}" ' + f'value {value_num}' + ) + + if len(values) == 1 and values[0].get('weight', 1) != 1: + reasons.append( + f'pool "{_id}" has single value with weight!=1' + ) + + fallback = pool.get('fallback', None) + if fallback is not None: + if fallback in pools: + pools_seen_as_fallback.add(fallback) + else: + reasons.append( + f'undefined fallback "{fallback}" ' + f'for pool "{_id}"' + ) + + # Check for loops + fallback = pools[_id].get('fallback', None) + seen = [_id, fallback] + while fallback is not None: + # See if there's a next fallback + fallback = pools.get(fallback, {}).get('fallback', None) + if fallback in seen: + loop = ' -> '.join(seen) + reasons.append(f'loop in pool fallbacks: {loop}') + # exit the loop + break + seen.append(fallback) + + try: + rules = data['dynamic']['rules'] + except KeyError: + rules = [] + + if not isinstance(rules, (list, tuple)): + reasons.append('rules must be a list') + elif not rules: + reasons.append('missing rules') + else: + seen_default = False + + for i, rule in enumerate(rules): + rule_num = i + 1 + try: + pool = rule['pool'] + except KeyError: + reasons.append(f'rule {rule_num} missing pool') + continue + + try: + geos = rule['geos'] + except KeyError: + geos = [] + + if not isinstance(pool, str): + reasons.append(f'rule {rule_num} invalid pool "{pool}"') + else: + if pool not in pools: + reasons.append( + f'rule {rule_num} undefined pool ' f'"{pool}"' + ) + elif pool in pools_seen and geos: + reasons.append( + f'rule {rule_num} invalid, target ' + f'pool "{pool}" reused' + ) + pools_seen.add(pool) + + if not geos: + if seen_default: + reasons.append(f'rule {rule_num} duplicate default') + seen_default = True + + if not isinstance(geos, (list, tuple)): + reasons.append(f'rule {rule_num} geos must be a list') + else: + for geo in geos: + reasons.extend( + GeoCodes.validate(geo, f'rule {rule_num} ') + ) + + unused = pools_exist - pools_seen - pools_seen_as_fallback + if unused: + unused = '", "'.join(sorted(unused)) + reasons.append(f'unused pools: "{unused}"') + + return reasons + + def __init__(self, zone, name, data, *args, **kwargs): + super().__init__(zone, name, data, *args, **kwargs) + + self.dynamic = {} + + if 'dynamic' not in data: + return + + # pools + try: + pools = dict(data['dynamic']['pools']) + except: + pools = {} + + for _id, pool in sorted(pools.items()): + pools[_id] = _DynamicPool(_id, pool, self._value_type) + + # rules + try: + rules = list(data['dynamic']['rules']) + except: + rules = [] + + parsed = [] + for i, rule in enumerate(rules): + parsed.append(_DynamicRule(i, rule)) + + # dynamic + self.dynamic = _Dynamic(pools, parsed) + + def _data(self): + ret = super()._data() + if self.dynamic: + ret['dynamic'] = self.dynamic._data() + return ret + + def changes(self, other, target): + if target.SUPPORTS_DYNAMIC: + if self.dynamic != other.dynamic: + return Update(self, other) + return super().changes(other, target) + + def __repr__(self): + # TODO: improve this whole thing, we need multi-line... + if self.dynamic: + # TODO: this hack can't going to cut it, as part of said + # improvements the value types should deal with serializing their + # value + try: + values = self.values + except AttributeError: + values = self.value + + klass = self.__class__.__name__ + return ( + f'<{klass} {self._type} {self.ttl}, {self.decoded_fqdn}, ' + f'{values}, {self.dynamic}>' + ) + return super().__repr__() diff --git a/octodns/record/exception.py b/octodns/record/exception.py new file mode 100644 index 0000000..2d27c8d --- /dev/null +++ b/octodns/record/exception.py @@ -0,0 +1,21 @@ +# +# +# + +from ..idna import idna_decode + + +class RecordException(Exception): + pass + + +class ValidationError(RecordException): + @classmethod + def build_message(cls, fqdn, reasons): + reasons = '\n - '.join(reasons) + return f'Invalid record {idna_decode(fqdn)}\n - {reasons}' + + def __init__(self, fqdn, reasons): + super().__init__(self.build_message(fqdn, reasons)) + self.fqdn = fqdn + self.reasons = reasons diff --git a/octodns/record/geo.py b/octodns/record/geo.py index 73bffe2..71b5db9 100644 --- a/octodns/record/geo.py +++ b/octodns/record/geo.py @@ -3,7 +3,11 @@ # from logging import getLogger +import re +from ..equality import EqualityTupleMixin +from .base import ValuesMixin +from .change import Update from .geo_data import geo_data @@ -79,3 +83,100 @@ class GeoCodes(object): if province in geo_data['NA']['CA']['provinces']: country = 'CA' return f'NA-{country}-{province}' + + +class GeoValue(EqualityTupleMixin): + geo_re = re.compile( + r'^(?P\w\w)(-(?P\w\w)' + r'(-(?P\w\w))?)?$' + ) + + @classmethod + def _validate_geo(cls, code): + reasons = [] + match = cls.geo_re.match(code) + if not match: + reasons.append(f'invalid geo "{code}"') + return reasons + + def __init__(self, geo, values): + self.code = geo + match = self.geo_re.match(geo) + self.continent_code = match.group('continent_code') + self.country_code = match.group('country_code') + self.subdivision_code = match.group('subdivision_code') + self.values = sorted(values) + + @property + def parents(self): + bits = self.code.split('-')[:-1] + while bits: + yield '-'.join(bits) + bits.pop() + + def _equality_tuple(self): + return ( + self.continent_code, + self.country_code, + self.subdivision_code, + self.values, + ) + + def __repr__(self): + return ( + f"'Geo {self.continent_code} {self.country_code} " + "{self.subdivision_code} {self.values}'" + ) + + +class _GeoMixin(ValuesMixin): + ''' + Adds GeoDNS support to a record. + + Must be included before `Record`. + ''' + + @classmethod + def validate(cls, name, fqdn, data): + reasons = super().validate(name, fqdn, data) + try: + geo = dict(data['geo']) + for code, values in geo.items(): + reasons.extend(GeoValue._validate_geo(code)) + reasons.extend(cls._value_type.validate(values, cls._type)) + except KeyError: + pass + return reasons + + def __init__(self, zone, name, data, *args, **kwargs): + super().__init__(zone, name, data, *args, **kwargs) + try: + self.geo = dict(data['geo']) + except KeyError: + self.geo = {} + for code, values in self.geo.items(): + self.geo[code] = GeoValue(code, values) + + def _data(self): + ret = super()._data() + if self.geo: + geo = {} + for code, value in self.geo.items(): + geo[code] = value.values + ret['geo'] = geo + return ret + + def changes(self, other, target): + if target.SUPPORTS_GEO: + if self.geo != other.geo: + return Update(self, other) + return super().changes(other, target) + + def __repr__(self): + if self.geo: + klass = self.__class__.__name__ + return ( + f'<{klass} {self._type} {self.ttl}, {self.decoded_fqdn}, ' + f'{self.values}, {self.geo}>' + ) + return super().__repr__() diff --git a/octodns/record/ipaddress.py b/octodns/record/ipaddress.py new file mode 100644 index 0000000..3c2a44a --- /dev/null +++ b/octodns/record/ipaddress.py @@ -0,0 +1,78 @@ +# +# +# + +from ipaddress import IPv4Address as _IPv4Address, IPv6Address as _IPv6Address + +from .base import Record +from .dynamic import _DynamicMixin +from .geo import _GeoMixin + + +class _IpAddress(str): + @classmethod + def parse_rdata_text(cls, value): + return value + + @classmethod + def validate(cls, data, _type): + if not isinstance(data, (list, tuple)): + data = (data,) + if len(data) == 0: + return ['missing value(s)'] + reasons = [] + for value in data: + if value == '': + reasons.append('empty value') + elif value is None: + reasons.append('missing value(s)') + else: + try: + cls._address_type(str(value)) + except Exception: + addr_name = cls._address_name + reasons.append(f'invalid {addr_name} address "{value}"') + return reasons + + @classmethod + def process(cls, values): + # Translating None into '' so that the list will be sortable in + # python3, get everything to str first + values = [v if v is not None else '' for v in values] + # Now round trip all non-'' through the address type and back to a str + # to normalize the address representation. + return [cls(v) if v != '' else '' for v in values] + + def __new__(cls, v): + v = str(cls._address_type(v)) + return super().__new__(cls, v) + + @property + def rdata_text(self): + return self + + +class Ipv4Address(_IpAddress): + _address_type = _IPv4Address + _address_name = 'IPv4' + + +class ARecord(_DynamicMixin, _GeoMixin, Record): + _type = 'A' + _value_type = Ipv4Address + + +Record.register_type(ARecord) + + +class Ipv6Address(_IpAddress): + _address_type = _IPv6Address + _address_name = 'IPv6' + + +class AaaaRecord(_DynamicMixin, _GeoMixin, Record): + _type = 'AAAA' + _value_type = Ipv6Address + + +Record.register_type(AaaaRecord) diff --git a/octodns/record/rr.py b/octodns/record/rr.py new file mode 100644 index 0000000..cfda184 --- /dev/null +++ b/octodns/record/rr.py @@ -0,0 +1,27 @@ +# +# +# + +from .exception import RecordException + + +class RrParseError(RecordException): + def __init__(self, message='failed to parse string value as RR text'): + super().__init__(message) + + +class Rr(object): + ''' + Simple object intended to be used with Record.from_rrs to allow providers + that work with RFC formatted rdata to share centralized parsing/encoding + code + ''' + + def __init__(self, name, _type, ttl, rdata): + self.name = name + self._type = _type + self.ttl = ttl + self.rdata = rdata + + def __repr__(self): + return f'Rr<{self.name}, {self._type}, {self.ttl}, {self.rdata}' diff --git a/octodns/record/target.py b/octodns/record/target.py new file mode 100644 index 0000000..31e23cb --- /dev/null +++ b/octodns/record/target.py @@ -0,0 +1,163 @@ +# +# +# + +from fqdn import FQDN + +from ..idna import idna_encode +from .base import Record, ValueMixin, ValuesMixin +from .dynamic import _DynamicMixin + + +class _TargetValue(str): + @classmethod + def parse_rdata_text(self, value): + return value + + @classmethod + def validate(cls, data, _type): + reasons = [] + if data == '': + reasons.append('empty value') + elif not data: + reasons.append('missing value') + else: + data = idna_encode(data) + if not FQDN(str(data), allow_underscores=True).is_valid: + reasons.append(f'{_type} value "{data}" is not a valid FQDN') + elif not data.endswith('.'): + reasons.append(f'{_type} value "{data}" missing trailing .') + return reasons + + @classmethod + def process(cls, value): + if value: + return cls(value) + return None + + def __new__(cls, v): + v = idna_encode(v) + return super().__new__(cls, v) + + @property + def rdata_text(self): + return self + + +# +# much like _TargetValue, but geared towards multiple values +class _TargetsValue(str): + @classmethod + def parse_rdata_text(cls, value): + return value + + @classmethod + def validate(cls, data, _type): + if not data: + return ['missing value(s)'] + elif not isinstance(data, (list, tuple)): + data = (data,) + reasons = [] + for value in data: + value = idna_encode(value) + if not FQDN(value, allow_underscores=True).is_valid: + reasons.append( + f'Invalid {_type} value "{value}" is not a valid FQDN.' + ) + elif not value.endswith('.'): + reasons.append(f'{_type} value "{value}" missing trailing .') + return reasons + + @classmethod + def process(cls, values): + return [cls(v) for v in values] + + def __new__(cls, v): + v = idna_encode(v) + return super().__new__(cls, v) + + @property + def rdata_text(self): + return self + + +class AliasValue(_TargetValue): + pass + + +class AliasRecord(ValueMixin, Record): + _type = 'ALIAS' + _value_type = AliasValue + + @classmethod + def validate(cls, name, fqdn, data): + reasons = [] + if name != '': + reasons.append('non-root ALIAS not allowed') + reasons.extend(super().validate(name, fqdn, data)) + return reasons + + +Record.register_type(AliasRecord) + + +class CnameValue(_TargetValue): + pass + + +class CnameRecord(_DynamicMixin, ValueMixin, Record): + _type = 'CNAME' + _value_type = CnameValue + + @classmethod + def validate(cls, name, fqdn, data): + reasons = [] + if name == '': + reasons.append('root CNAME not allowed') + reasons.extend(super().validate(name, fqdn, data)) + return reasons + + +Record.register_type(CnameRecord) + + +class DnameValue(_TargetValue): + pass + + +class DnameRecord(_DynamicMixin, ValueMixin, Record): + _type = 'DNAME' + _value_type = DnameValue + + +Record.register_type(DnameRecord) + + +class NsValue(_TargetsValue): + pass + + +class NsRecord(ValuesMixin, Record): + _type = 'NS' + _value_type = NsValue + + +Record.register_type(NsRecord) + + +class PtrValue(_TargetsValue): + pass + + +class PtrRecord(ValuesMixin, Record): + _type = 'PTR' + _value_type = PtrValue + + # This is for backward compatibility with providers that don't support + # multi-value PTR records. + @property + def value(self): + return self.values[0] + + +Record.register_type(PtrRecord) diff --git a/tests/test_octodns_provider_yaml.py b/tests/test_octodns_provider_yaml.py index fbd0809..417a684 100644 --- a/tests/test_octodns_provider_yaml.py +++ b/tests/test_octodns_provider_yaml.py @@ -9,7 +9,7 @@ from yaml import safe_load from yaml.constructor import ConstructorError from octodns.idna import idna_encode -from octodns.record import _NsValue, Create, Record, ValuesMixin +from octodns.record import NsValue, Create, Record, ValuesMixin from octodns.provider import ProviderException from octodns.provider.base import Plan from octodns.provider.yaml import ( @@ -273,7 +273,7 @@ xn--dj-kia8a: class YamlRecord(ValuesMixin, Record): _type = 'YAML' - _value_type = _NsValue + _value_type = NsValue # don't know anything about a yaml type self.assertTrue('YAML' not in source.SUPPORTS) diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index a7d8445..51c9ad7 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -26,6 +26,7 @@ from octodns.record import ( NaptrRecord, NaptrValue, NsRecord, + NsValue, PtrRecord, PtrValue, Record, @@ -46,12 +47,9 @@ from octodns.record import ( ValidationError, ValuesMixin, _ChunkedValue, - _Dynamic, - _DynamicPool, - _DynamicRule, - _NsValue, - _TargetValue, ) +from octodns.record.dynamic import _Dynamic, _DynamicPool, _DynamicRule +from octodns.record.target import _TargetValue from octodns.zone import Zone from helpers import DynamicProvider, GeoProvider, SimpleProvider @@ -64,13 +62,13 @@ class TestRecord(TestCase): with self.assertRaises(RecordException) as ctx: Record.register_type(None, 'A') self.assertEqual( - 'Type "A" already registered by octodns.record.ARecord', + 'Type "A" already registered by octodns.record.ipaddress.ARecord', str(ctx.exception), ) class AaRecord(ValuesMixin, Record): _type = 'AA' - _value_type = _NsValue + _value_type = NsValue self.assertTrue('AA' not in Record.registered_types()) @@ -1514,7 +1512,7 @@ class TestRecord(TestCase): '1.2.word.4', '1.2.3.4', ): - self.assertEqual(s, _NsValue.parse_rdata_text(s)) + self.assertEqual(s, NsValue.parse_rdata_text(s)) zone = Zone('unit.tests.', []) a = NsRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) From 2c9c0518a544e11728ac4e79bfb8a87fedfcde85 Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Mon, 2 Jan 2023 10:24:53 -0500 Subject: [PATCH 2/5] Include Change in octodns.record, some module tests use it --- octodns/record/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index 516acf4..389fb56 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -11,7 +11,7 @@ from ..idna import idna_encode from .geo import GeoCodes, GeoValue from .base import Record, ValueMixin, ValuesMixin -from .change import Create, Delete, Update +from .change import Change, Create, Delete, Update from .exception import RecordException, ValidationError from .rr import Rr, RrParseError from .target import ( @@ -33,6 +33,7 @@ ARecord AaaaRecord AliasRecord AliasValue +Change CnameRecord CnameValue Create From 965ee566bb403ec03c9e64cb88ccd5da7a17c98b Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Mon, 2 Jan 2023 14:33:44 -0500 Subject: [PATCH 3/5] Pull out rest of record into specific files --- octodns/record/__init__.py | 1499 +--------------------------------- octodns/record/caa.py | 101 +++ octodns/record/chunked.py | 83 ++ octodns/record/ds.py | 136 +++ octodns/record/loc.py | 358 ++++++++ octodns/record/mx.py | 120 +++ octodns/record/naptr.py | 172 ++++ octodns/record/srv.py | 158 ++++ octodns/record/sshfp.py | 124 +++ octodns/record/tlsa.py | 160 ++++ octodns/record/url.py | 121 +++ tests/test_octodns_record.py | 2 +- 12 files changed, 1568 insertions(+), 1466 deletions(-) create mode 100644 octodns/record/caa.py create mode 100644 octodns/record/chunked.py create mode 100644 octodns/record/ds.py create mode 100644 octodns/record/loc.py create mode 100644 octodns/record/mx.py create mode 100644 octodns/record/naptr.py create mode 100644 octodns/record/srv.py create mode 100644 octodns/record/sshfp.py create mode 100644 octodns/record/tlsa.py create mode 100644 octodns/record/url.py diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index 389fb56..8f16484 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -2,18 +2,21 @@ # # -import re - -from fqdn import FQDN - -from ..equality import EqualityTupleMixin -from ..idna import idna_encode -from .geo import GeoCodes, GeoValue from .base import Record, ValueMixin, ValuesMixin from .change import Change, Create, Delete, Update +from .caa import CaaRecord, CaaValue +from .ds import DsRecord, DsValue from .exception import RecordException, ValidationError +from .geo import GeoCodes, GeoValue +from .loc import LocRecord, LocValue +from .mx import MxRecord, MxValue +from .naptr import NaptrRecord, NaptrValue from .rr import Rr, RrParseError +from .tlsa import TlsaRecord, TlsaValue +from .url import UrlfwdRecord, UrlfwdValue +from .srv import SrvRecord, SrvValue +from .sshfp import SshfpRecord, SshfpValue from .target import ( AliasRecord, AliasValue, @@ -27,12 +30,15 @@ from .target import ( PtrRecord, ) from .ipaddress import ARecord, AaaaRecord, Ipv4Address, Ipv6Address +from .chunked import SpfRecord, TxtValue, TxtRecord # quell warnings ARecord AaaaRecord AliasRecord AliasValue +CaaRecord +CaaValue Change CnameRecord CnameValue @@ -40,1475 +46,38 @@ Create Delete DnameRecord DnameValue +DsRecord +DsValue GeoCodes GeoValue Ipv4Address Ipv6Address +LocRecord +LocValue +MxRecord +MxValue +NaptrRecord +NaptrValue NsRecord NsValue PtrRecord PtrValue +Record RecordException Rr +RrParseError +SpfRecord +SrvRecord +SrvValue +SshfpRecord +SshfpValue +TlsaRecord +TlsaValue +TxtRecord +TxtValue Update +UrlfwdRecord +UrlfwdValue ValidationError ValueMixin ValuesMixin - - -class DsValue(EqualityTupleMixin, dict): - # https://www.rfc-editor.org/rfc/rfc4034.html#section-2.1 - - @classmethod - def parse_rdata_text(cls, value): - try: - flags, protocol, algorithm, public_key = value.split(' ') - except ValueError: - raise RrParseError() - try: - flags = int(flags) - except ValueError: - pass - try: - protocol = int(protocol) - except ValueError: - pass - try: - algorithm = int(algorithm) - except ValueError: - pass - return { - 'flags': flags, - 'protocol': protocol, - 'algorithm': algorithm, - 'public_key': public_key, - } - - @classmethod - def validate(cls, data, _type): - if not isinstance(data, (list, tuple)): - data = (data,) - reasons = [] - for value in data: - try: - int(value['flags']) - except KeyError: - reasons.append('missing flags') - except ValueError: - reasons.append(f'invalid flags "{value["flags"]}"') - try: - int(value['protocol']) - except KeyError: - reasons.append('missing protocol') - except ValueError: - reasons.append(f'invalid protocol "{value["protocol"]}"') - try: - int(value['algorithm']) - except KeyError: - reasons.append('missing algorithm') - except ValueError: - reasons.append(f'invalid algorithm "{value["algorithm"]}"') - if 'public_key' not in value: - reasons.append('missing public_key') - return reasons - - @classmethod - def process(cls, values): - return [cls(v) for v in values] - - def __init__(self, value): - super().__init__( - { - 'flags': int(value['flags']), - 'protocol': int(value['protocol']), - 'algorithm': int(value['algorithm']), - 'public_key': value['public_key'], - } - ) - - @property - def flags(self): - return self['flags'] - - @flags.setter - def flags(self, value): - self['flags'] = value - - @property - def protocol(self): - return self['protocol'] - - @protocol.setter - def protocol(self, value): - self['protocol'] = value - - @property - def algorithm(self): - return self['algorithm'] - - @algorithm.setter - def algorithm(self, value): - self['algorithm'] = value - - @property - def public_key(self): - return self['public_key'] - - @public_key.setter - def public_key(self, value): - self['public_key'] = value - - @property - def data(self): - return self - - @property - def rdata_text(self): - return ( - f'{self.flags} {self.protocol} {self.algorithm} {self.public_key}' - ) - - def _equality_tuple(self): - return (self.flags, self.protocol, self.algorithm, self.public_key) - - def __repr__(self): - return ( - f'{self.flags} {self.protocol} {self.algorithm} {self.public_key}' - ) - - -class DsRecord(ValuesMixin, Record): - _type = 'DS' - _value_type = DsValue - - -Record.register_type(DsRecord) - - -class CaaValue(EqualityTupleMixin, dict): - # https://tools.ietf.org/html/rfc6844#page-5 - - @classmethod - def parse_rdata_text(cls, value): - try: - flags, tag, value = value.split(' ') - except ValueError: - raise RrParseError() - try: - flags = int(flags) - except ValueError: - pass - return {'flags': flags, 'tag': tag, 'value': value} - - @classmethod - def validate(cls, data, _type): - if not isinstance(data, (list, tuple)): - data = (data,) - reasons = [] - for value in data: - try: - flags = int(value.get('flags', 0)) - if flags < 0 or flags > 255: - reasons.append(f'invalid flags "{flags}"') - except ValueError: - reasons.append(f'invalid flags "{value["flags"]}"') - - if 'tag' not in value: - reasons.append('missing tag') - if 'value' not in value: - reasons.append('missing value') - return reasons - - @classmethod - def process(cls, values): - return [cls(v) for v in values] - - def __init__(self, value): - super().__init__( - { - 'flags': int(value.get('flags', 0)), - 'tag': value['tag'], - 'value': value['value'], - } - ) - - @property - def flags(self): - return self['flags'] - - @flags.setter - def flags(self, value): - self['flags'] = value - - @property - def tag(self): - return self['tag'] - - @tag.setter - def tag(self, value): - self['tag'] = value - - @property - def value(self): - return self['value'] - - @value.setter - def value(self, value): - self['value'] = value - - @property - def data(self): - return self - - @property - def rdata_text(self): - return f'{self.flags} {self.tag} {self.value}' - - def _equality_tuple(self): - return (self.flags, self.tag, self.value) - - def __repr__(self): - return f'{self.flags} {self.tag} "{self.value}"' - - -class CaaRecord(ValuesMixin, Record): - _type = 'CAA' - _value_type = CaaValue - - -Record.register_type(CaaRecord) - - -class LocValue(EqualityTupleMixin, dict): - # TODO: this does not really match the RFC, but it's stuck using the details - # of how the type was impelemented. Would be nice to rework things to match - # while maintaining backwards compatibility. - # https://www.rfc-editor.org/rfc/rfc1876.html - - @classmethod - def parse_rdata_text(cls, value): - try: - value = value.replace('m', '') - ( - lat_degrees, - lat_minutes, - lat_seconds, - lat_direction, - long_degrees, - long_minutes, - long_seconds, - long_direction, - altitude, - size, - precision_horz, - precision_vert, - ) = value.split(' ') - except ValueError: - raise RrParseError() - try: - lat_degrees = int(lat_degrees) - except ValueError: - pass - try: - lat_minutes = int(lat_minutes) - except ValueError: - pass - try: - long_degrees = int(long_degrees) - except ValueError: - pass - try: - long_minutes = int(long_minutes) - except ValueError: - pass - try: - lat_seconds = float(lat_seconds) - except ValueError: - pass - try: - long_seconds = float(long_seconds) - except ValueError: - pass - try: - altitude = float(altitude) - except ValueError: - pass - try: - size = float(size) - except ValueError: - pass - try: - precision_horz = float(precision_horz) - except ValueError: - pass - try: - precision_vert = float(precision_vert) - except ValueError: - pass - return { - 'lat_degrees': lat_degrees, - 'lat_minutes': lat_minutes, - 'lat_seconds': lat_seconds, - 'lat_direction': lat_direction, - 'long_degrees': long_degrees, - 'long_minutes': long_minutes, - 'long_seconds': long_seconds, - 'long_direction': long_direction, - 'altitude': altitude, - 'size': size, - 'precision_horz': precision_horz, - 'precision_vert': precision_vert, - } - - @classmethod - def validate(cls, data, _type): - int_keys = [ - 'lat_degrees', - 'lat_minutes', - 'long_degrees', - 'long_minutes', - ] - - float_keys = [ - 'lat_seconds', - 'long_seconds', - 'altitude', - 'size', - 'precision_horz', - 'precision_vert', - ] - - direction_keys = ['lat_direction', 'long_direction'] - - if not isinstance(data, (list, tuple)): - data = (data,) - reasons = [] - for value in data: - for key in int_keys: - try: - int(value[key]) - if ( - ( - key == 'lat_degrees' - and not 0 <= int(value[key]) <= 90 - ) - or ( - key == 'long_degrees' - and not 0 <= int(value[key]) <= 180 - ) - or ( - key in ['lat_minutes', 'long_minutes'] - and not 0 <= int(value[key]) <= 59 - ) - ): - reasons.append( - f'invalid value for {key} ' f'"{value[key]}"' - ) - except KeyError: - reasons.append(f'missing {key}') - except ValueError: - reasons.append(f'invalid {key} "{value[key]}"') - - for key in float_keys: - try: - float(value[key]) - if ( - ( - key in ['lat_seconds', 'long_seconds'] - and not 0 <= float(value[key]) <= 59.999 - ) - or ( - key == 'altitude' - and not -100000.00 - <= float(value[key]) - <= 42849672.95 - ) - or ( - key in ['size', 'precision_horz', 'precision_vert'] - and not 0 <= float(value[key]) <= 90000000.00 - ) - ): - reasons.append( - f'invalid value for {key} ' f'"{value[key]}"' - ) - except KeyError: - reasons.append(f'missing {key}') - except ValueError: - reasons.append(f'invalid {key} "{value[key]}"') - - for key in direction_keys: - try: - str(value[key]) - if key == 'lat_direction' and value[key] not in ['N', 'S']: - reasons.append( - f'invalid direction for {key} ' f'"{value[key]}"' - ) - if key == 'long_direction' and value[key] not in ['E', 'W']: - reasons.append( - f'invalid direction for {key} ' f'"{value[key]}"' - ) - except KeyError: - reasons.append(f'missing {key}') - return reasons - - @classmethod - def process(cls, values): - return [cls(v) for v in values] - - def __init__(self, value): - super().__init__( - { - 'lat_degrees': int(value['lat_degrees']), - 'lat_minutes': int(value['lat_minutes']), - 'lat_seconds': float(value['lat_seconds']), - 'lat_direction': value['lat_direction'].upper(), - 'long_degrees': int(value['long_degrees']), - 'long_minutes': int(value['long_minutes']), - 'long_seconds': float(value['long_seconds']), - 'long_direction': value['long_direction'].upper(), - 'altitude': float(value['altitude']), - 'size': float(value['size']), - 'precision_horz': float(value['precision_horz']), - 'precision_vert': float(value['precision_vert']), - } - ) - - @property - def lat_degrees(self): - return self['lat_degrees'] - - @lat_degrees.setter - def lat_degrees(self, value): - self['lat_degrees'] = value - - @property - def lat_minutes(self): - return self['lat_minutes'] - - @lat_minutes.setter - def lat_minutes(self, value): - self['lat_minutes'] = value - - @property - def lat_seconds(self): - return self['lat_seconds'] - - @lat_seconds.setter - def lat_seconds(self, value): - self['lat_seconds'] = value - - @property - def lat_direction(self): - return self['lat_direction'] - - @lat_direction.setter - def lat_direction(self, value): - self['lat_direction'] = value - - @property - def long_degrees(self): - return self['long_degrees'] - - @long_degrees.setter - def long_degrees(self, value): - self['long_degrees'] = value - - @property - def long_minutes(self): - return self['long_minutes'] - - @long_minutes.setter - def long_minutes(self, value): - self['long_minutes'] = value - - @property - def long_seconds(self): - return self['long_seconds'] - - @long_seconds.setter - def long_seconds(self, value): - self['long_seconds'] = value - - @property - def long_direction(self): - return self['long_direction'] - - @long_direction.setter - def long_direction(self, value): - self['long_direction'] = value - - @property - def altitude(self): - return self['altitude'] - - @altitude.setter - def altitude(self, value): - self['altitude'] = value - - @property - def size(self): - return self['size'] - - @size.setter - def size(self, value): - self['size'] = value - - @property - def precision_horz(self): - return self['precision_horz'] - - @precision_horz.setter - def precision_horz(self, value): - self['precision_horz'] = value - - @property - def precision_vert(self): - return self['precision_vert'] - - @precision_vert.setter - def precision_vert(self, value): - self['precision_vert'] = value - - @property - def data(self): - return self - - @property - def rdata_text(self): - return f'{self.lat_degrees} {self.lat_minutes} {self.lat_seconds} {self.lat_direction} {self.long_degrees} {self.long_minutes} {self.long_seconds} {self.long_direction} {self.altitude}m {self.size}m {self.precision_horz}m {self.precision_vert}m' - - def __hash__(self): - return hash( - ( - self.lat_degrees, - self.lat_minutes, - self.lat_seconds, - self.lat_direction, - self.long_degrees, - self.long_minutes, - self.long_seconds, - self.long_direction, - self.altitude, - self.size, - self.precision_horz, - self.precision_vert, - ) - ) - - def _equality_tuple(self): - return ( - self.lat_degrees, - self.lat_minutes, - self.lat_seconds, - self.lat_direction, - self.long_degrees, - self.long_minutes, - self.long_seconds, - self.long_direction, - self.altitude, - self.size, - self.precision_horz, - self.precision_vert, - ) - - def __repr__(self): - return ( - f"'{self.lat_degrees} {self.lat_minutes} " - f"{self.lat_seconds:.3f} {self.lat_direction} " - f"{self.long_degrees} {self.long_minutes} " - f"{self.long_seconds:.3f} {self.long_direction} " - f"{self.altitude:.2f}m {self.size:.2f}m " - f"{self.precision_horz:.2f}m {self.precision_vert:.2f}m'" - ) - - -class LocRecord(ValuesMixin, Record): - _type = 'LOC' - _value_type = LocValue - - -Record.register_type(LocRecord) - - -class MxValue(EqualityTupleMixin, dict): - @classmethod - def parse_rdata_text(cls, value): - try: - preference, exchange = value.split(' ') - except ValueError: - raise RrParseError() - try: - preference = int(preference) - except ValueError: - pass - return {'preference': preference, 'exchange': exchange} - - @classmethod - def validate(cls, data, _type): - if not isinstance(data, (list, tuple)): - data = (data,) - reasons = [] - for value in data: - try: - try: - int(value['preference']) - except KeyError: - int(value['priority']) - except KeyError: - reasons.append('missing preference') - except ValueError: - reasons.append(f'invalid preference "{value["preference"]}"') - exchange = None - try: - exchange = value.get('exchange', None) or value['value'] - if not exchange: - reasons.append('missing exchange') - continue - exchange = idna_encode(exchange) - if ( - exchange != '.' - and not FQDN(exchange, allow_underscores=True).is_valid - ): - reasons.append( - f'Invalid MX exchange "{exchange}" is not ' - 'a valid FQDN.' - ) - elif not exchange.endswith('.'): - reasons.append(f'MX value "{exchange}" missing trailing .') - except KeyError: - reasons.append('missing exchange') - return reasons - - @classmethod - def process(cls, values): - return [cls(v) for v in values] - - def __init__(self, value): - # RFC1035 says preference, half the providers use priority - try: - preference = value['preference'] - except KeyError: - preference = value['priority'] - # UNTIL 1.0 remove value fallback - try: - exchange = value['exchange'] - except KeyError: - exchange = value['value'] - super().__init__( - {'preference': int(preference), 'exchange': idna_encode(exchange)} - ) - - @property - def preference(self): - return self['preference'] - - @preference.setter - def preference(self, value): - self['preference'] = value - - @property - def exchange(self): - return self['exchange'] - - @exchange.setter - def exchange(self, value): - self['exchange'] = value - - @property - def data(self): - return self - - @property - def rdata_text(self): - return f'{self.preference} {self.exchange}' - - def __hash__(self): - return hash((self.preference, self.exchange)) - - def _equality_tuple(self): - return (self.preference, self.exchange) - - def __repr__(self): - return f"'{self.preference} {self.exchange}'" - - -class MxRecord(ValuesMixin, Record): - _type = 'MX' - _value_type = MxValue - - -Record.register_type(MxRecord) - - -class NaptrValue(EqualityTupleMixin, dict): - VALID_FLAGS = ('S', 'A', 'U', 'P') - - @classmethod - def parse_rdata_text(cls, value): - try: - ( - order, - preference, - flags, - service, - regexp, - replacement, - ) = value.split(' ') - except ValueError: - raise RrParseError() - try: - order = int(order) - preference = int(preference) - except ValueError: - pass - return { - 'order': order, - 'preference': preference, - 'flags': flags, - 'service': service, - 'regexp': regexp, - 'replacement': replacement, - } - - @classmethod - def validate(cls, data, _type): - if not isinstance(data, (list, tuple)): - data = (data,) - reasons = [] - for value in data: - try: - int(value['order']) - except KeyError: - reasons.append('missing order') - except ValueError: - reasons.append(f'invalid order "{value["order"]}"') - try: - int(value['preference']) - except KeyError: - reasons.append('missing preference') - except ValueError: - reasons.append(f'invalid preference "{value["preference"]}"') - try: - flags = value['flags'] - if flags not in cls.VALID_FLAGS: - reasons.append(f'unrecognized flags "{flags}"') - except KeyError: - reasons.append('missing flags') - - # TODO: validate these... they're non-trivial - for k in ('service', 'regexp', 'replacement'): - if k not in value: - reasons.append(f'missing {k}') - - return reasons - - @classmethod - def process(cls, values): - return [cls(v) for v in values] - - def __init__(self, value): - super().__init__( - { - 'order': int(value['order']), - 'preference': int(value['preference']), - 'flags': value['flags'], - 'service': value['service'], - 'regexp': value['regexp'], - 'replacement': value['replacement'], - } - ) - - @property - def order(self): - return self['order'] - - @order.setter - def order(self, value): - self['order'] = value - - @property - def preference(self): - return self['preference'] - - @preference.setter - def preference(self, value): - self['preference'] = value - - @property - def flags(self): - return self['flags'] - - @flags.setter - def flags(self, value): - self['flags'] = value - - @property - def service(self): - return self['service'] - - @service.setter - def service(self, value): - self['service'] = value - - @property - def regexp(self): - return self['regexp'] - - @regexp.setter - def regexp(self, value): - self['regexp'] = value - - @property - def replacement(self): - return self['replacement'] - - @replacement.setter - def replacement(self, value): - self['replacement'] = value - - @property - def data(self): - return self - - @property - def rdata_text(self): - return f'{self.order} {self.preference} {self.flags} {self.service} {self.regexp} {self.replacement}' - - def __hash__(self): - return hash(self.__repr__()) - - def _equality_tuple(self): - return ( - self.order, - self.preference, - self.flags, - self.service, - self.regexp, - self.replacement, - ) - - def __repr__(self): - flags = self.flags if self.flags is not None else '' - service = self.service if self.service is not None else '' - regexp = self.regexp if self.regexp is not None else '' - return ( - f"'{self.order} {self.preference} \"{flags}\" \"{service}\" " - f"\"{regexp}\" {self.replacement}'" - ) - - -class NaptrRecord(ValuesMixin, Record): - _type = 'NAPTR' - _value_type = NaptrValue - - -Record.register_type(NaptrRecord) - - -class SshfpValue(EqualityTupleMixin, dict): - VALID_ALGORITHMS = (1, 2, 3, 4) - VALID_FINGERPRINT_TYPES = (1, 2) - - @classmethod - def parse_rdata_text(self, value): - try: - algorithm, fingerprint_type, fingerprint = value.split(' ') - except ValueError: - raise RrParseError() - try: - algorithm = int(algorithm) - except ValueError: - pass - try: - fingerprint_type = int(fingerprint_type) - except ValueError: - pass - return { - 'algorithm': algorithm, - 'fingerprint_type': fingerprint_type, - 'fingerprint': fingerprint, - } - - @classmethod - def validate(cls, data, _type): - if not isinstance(data, (list, tuple)): - data = (data,) - reasons = [] - for value in data: - try: - algorithm = int(value['algorithm']) - if algorithm not in cls.VALID_ALGORITHMS: - reasons.append(f'unrecognized algorithm "{algorithm}"') - except KeyError: - reasons.append('missing algorithm') - except ValueError: - reasons.append(f'invalid algorithm "{value["algorithm"]}"') - try: - fingerprint_type = int(value['fingerprint_type']) - if fingerprint_type not in cls.VALID_FINGERPRINT_TYPES: - reasons.append( - 'unrecognized fingerprint_type ' f'"{fingerprint_type}"' - ) - except KeyError: - reasons.append('missing fingerprint_type') - except ValueError: - reasons.append( - 'invalid fingerprint_type ' f'"{value["fingerprint_type"]}"' - ) - if 'fingerprint' not in value: - reasons.append('missing fingerprint') - return reasons - - @classmethod - def process(cls, values): - return [cls(v) for v in values] - - def __init__(self, value): - super().__init__( - { - 'algorithm': int(value['algorithm']), - 'fingerprint_type': int(value['fingerprint_type']), - 'fingerprint': value['fingerprint'], - } - ) - - @property - def algorithm(self): - return self['algorithm'] - - @algorithm.setter - def algorithm(self, value): - self['algorithm'] = value - - @property - def fingerprint_type(self): - return self['fingerprint_type'] - - @fingerprint_type.setter - def fingerprint_type(self, value): - self['fingerprint_type'] = value - - @property - def fingerprint(self): - return self['fingerprint'] - - @fingerprint.setter - def fingerprint(self, value): - self['fingerprint'] = value - - @property - def data(self): - return self - - @property - def rdata_text(self): - return f'{self.algorithm} {self.fingerprint_type} {self.fingerprint}' - - def __hash__(self): - return hash(self.__repr__()) - - def _equality_tuple(self): - return (self.algorithm, self.fingerprint_type, self.fingerprint) - - def __repr__(self): - return f"'{self.algorithm} {self.fingerprint_type} {self.fingerprint}'" - - -class SshfpRecord(ValuesMixin, Record): - _type = 'SSHFP' - _value_type = SshfpValue - - -Record.register_type(SshfpRecord) - - -class _ChunkedValuesMixin(ValuesMixin): - CHUNK_SIZE = 255 - _unescaped_semicolon_re = re.compile(r'\w;') - - def chunked_value(self, value): - value = value.replace('"', '\\"') - vs = [ - value[i : i + self.CHUNK_SIZE] - for i in range(0, len(value), self.CHUNK_SIZE) - ] - vs = '" "'.join(vs) - return f'"{vs}"' - - @property - def chunked_values(self): - values = [] - for v in self.values: - values.append(self.chunked_value(v)) - return values - - -class _ChunkedValue(str): - _unescaped_semicolon_re = re.compile(r'\w;') - - @classmethod - def parse_rdata_text(cls, value): - try: - return value.replace(';', '\\;') - except AttributeError: - return value - - @classmethod - def validate(cls, data, _type): - if not data: - return ['missing value(s)'] - elif not isinstance(data, (list, tuple)): - data = (data,) - reasons = [] - for value in data: - if cls._unescaped_semicolon_re.search(value): - reasons.append(f'unescaped ; in "{value}"') - return reasons - - @classmethod - def process(cls, values): - ret = [] - for v in values: - if v and v[0] == '"': - v = v[1:-1] - ret.append(cls(v.replace('" "', ''))) - return ret - - @property - def rdata_text(self): - return self - - -class SpfRecord(_ChunkedValuesMixin, Record): - _type = 'SPF' - _value_type = _ChunkedValue - - -Record.register_type(SpfRecord) - - -class SrvValue(EqualityTupleMixin, dict): - @classmethod - def parse_rdata_text(self, value): - try: - priority, weight, port, target = value.split(' ') - except ValueError: - raise RrParseError() - try: - priority = int(priority) - except ValueError: - pass - try: - weight = int(weight) - except ValueError: - pass - try: - port = int(port) - except ValueError: - pass - return { - 'priority': priority, - 'weight': weight, - 'port': port, - 'target': target, - } - - @classmethod - def validate(cls, data, _type): - if not isinstance(data, (list, tuple)): - data = (data,) - reasons = [] - for value in data: - # TODO: validate algorithm and fingerprint_type values - try: - int(value['priority']) - except KeyError: - reasons.append('missing priority') - except ValueError: - reasons.append(f'invalid priority "{value["priority"]}"') - try: - int(value['weight']) - except KeyError: - reasons.append('missing weight') - except ValueError: - reasons.append(f'invalid weight "{value["weight"]}"') - try: - int(value['port']) - except KeyError: - reasons.append('missing port') - except ValueError: - reasons.append(f'invalid port "{value["port"]}"') - try: - target = value['target'] - if not target: - reasons.append('missing target') - continue - target = idna_encode(target) - if not target.endswith('.'): - reasons.append(f'SRV value "{target}" missing trailing .') - if ( - target != '.' - and not FQDN(target, allow_underscores=True).is_valid - ): - reasons.append( - f'Invalid SRV target "{target}" is not a valid FQDN.' - ) - except KeyError: - reasons.append('missing target') - return reasons - - @classmethod - def process(cls, values): - return [cls(v) for v in values] - - def __init__(self, value): - super().__init__( - { - 'priority': int(value['priority']), - 'weight': int(value['weight']), - 'port': int(value['port']), - 'target': idna_encode(value['target']), - } - ) - - @property - def priority(self): - return self['priority'] - - @priority.setter - def priority(self, value): - self['priority'] = value - - @property - def weight(self): - return self['weight'] - - @weight.setter - def weight(self, value): - self['weight'] = value - - @property - def port(self): - return self['port'] - - @port.setter - def port(self, value): - self['port'] = value - - @property - def target(self): - return self['target'] - - @target.setter - def target(self, value): - self['target'] = value - - @property - def data(self): - return self - - def __hash__(self): - return hash(self.__repr__()) - - def _equality_tuple(self): - return (self.priority, self.weight, self.port, self.target) - - def __repr__(self): - return f"'{self.priority} {self.weight} {self.port} {self.target}'" - - -class SrvRecord(ValuesMixin, Record): - _type = 'SRV' - _value_type = SrvValue - _name_re = re.compile(r'^(\*|_[^\.]+)\.[^\.]+') - - @classmethod - def validate(cls, name, fqdn, data): - reasons = [] - if not cls._name_re.match(name): - reasons.append('invalid name for SRV record') - reasons.extend(super().validate(name, fqdn, data)) - return reasons - - -Record.register_type(SrvRecord) - - -class TlsaValue(EqualityTupleMixin, dict): - @classmethod - def parse_rdata_text(self, value): - try: - ( - certificate_usage, - selector, - matching_type, - certificate_association_data, - ) = value.split(' ') - except ValueError: - raise RrParseError() - try: - certificate_usage = int(certificate_usage) - except ValueError: - pass - try: - selector = int(selector) - except ValueError: - pass - try: - matching_type = int(matching_type) - except ValueError: - pass - return { - 'certificate_usage': certificate_usage, - 'selector': selector, - 'matching_type': matching_type, - 'certificate_association_data': certificate_association_data, - } - - @classmethod - def validate(cls, data, _type): - if not isinstance(data, (list, tuple)): - data = (data,) - reasons = [] - for value in data: - try: - certificate_usage = int(value.get('certificate_usage', 0)) - if certificate_usage < 0 or certificate_usage > 3: - reasons.append( - f'invalid certificate_usage ' f'"{certificate_usage}"' - ) - except ValueError: - reasons.append( - f'invalid certificate_usage ' - f'"{value["certificate_usage"]}"' - ) - - try: - selector = int(value.get('selector', 0)) - if selector < 0 or selector > 1: - reasons.append(f'invalid selector "{selector}"') - except ValueError: - reasons.append(f'invalid selector "{value["selector"]}"') - - try: - matching_type = int(value.get('matching_type', 0)) - if matching_type < 0 or matching_type > 2: - reasons.append(f'invalid matching_type "{matching_type}"') - except ValueError: - reasons.append( - f'invalid matching_type ' f'"{value["matching_type"]}"' - ) - - if 'certificate_usage' not in value: - reasons.append('missing certificate_usage') - if 'selector' not in value: - reasons.append('missing selector') - if 'matching_type' not in value: - reasons.append('missing matching_type') - if 'certificate_association_data' not in value: - reasons.append('missing certificate_association_data') - return reasons - - @classmethod - def process(cls, values): - return [cls(v) for v in values] - - def __init__(self, value): - super().__init__( - { - 'certificate_usage': int(value.get('certificate_usage', 0)), - 'selector': int(value.get('selector', 0)), - 'matching_type': int(value.get('matching_type', 0)), - # force it to a string, in case the hex has only numerical - # values and it was converted to an int at some point - # TODO: this needed on any others? - 'certificate_association_data': str( - value['certificate_association_data'] - ), - } - ) - - @property - def certificate_usage(self): - return self['certificate_usage'] - - @certificate_usage.setter - def certificate_usage(self, value): - self['certificate_usage'] = value - - @property - def selector(self): - return self['selector'] - - @selector.setter - def selector(self, value): - self['selector'] = value - - @property - def matching_type(self): - return self['matching_type'] - - @matching_type.setter - def matching_type(self, value): - self['matching_type'] = value - - @property - def certificate_association_data(self): - return self['certificate_association_data'] - - @certificate_association_data.setter - def certificate_association_data(self, value): - self['certificate_association_data'] = value - - @property - def rdata_text(self): - return f'{self.certificate_usage} {self.selector} {self.matching_type} {self.certificate_association_data}' - - def _equality_tuple(self): - return ( - self.certificate_usage, - self.selector, - self.matching_type, - self.certificate_association_data, - ) - - def __repr__(self): - return ( - f"'{self.certificate_usage} {self.selector} '" - f"'{self.matching_type} {self.certificate_association_data}'" - ) - - -class TlsaRecord(ValuesMixin, Record): - _type = 'TLSA' - _value_type = TlsaValue - - -Record.register_type(TlsaRecord) - - -class _TxtValue(_ChunkedValue): - pass - - -class TxtRecord(_ChunkedValuesMixin, Record): - _type = 'TXT' - _value_type = _TxtValue - - -Record.register_type(TxtRecord) - - -class UrlfwdValue(EqualityTupleMixin, dict): - VALID_CODES = (301, 302) - VALID_MASKS = (0, 1, 2) - VALID_QUERY = (0, 1) - - @classmethod - def validate(cls, data, _type): - if not isinstance(data, (list, tuple)): - data = (data,) - reasons = [] - for value in data: - try: - code = int(value['code']) - if code not in cls.VALID_CODES: - reasons.append(f'unrecognized return code "{code}"') - except KeyError: - reasons.append('missing code') - except ValueError: - reasons.append(f'invalid return code "{value["code"]}"') - try: - masking = int(value['masking']) - if masking not in cls.VALID_MASKS: - reasons.append(f'unrecognized masking setting "{masking}"') - except KeyError: - reasons.append('missing masking') - except ValueError: - reasons.append(f'invalid masking setting "{value["masking"]}"') - try: - query = int(value['query']) - if query not in cls.VALID_QUERY: - reasons.append(f'unrecognized query setting "{query}"') - except KeyError: - reasons.append('missing query') - except ValueError: - reasons.append(f'invalid query setting "{value["query"]}"') - for k in ('path', 'target'): - if k not in value: - reasons.append(f'missing {k}') - return reasons - - @classmethod - def process(cls, values): - return [cls(v) for v in values] - - def __init__(self, value): - super().__init__( - { - 'path': value['path'], - 'target': value['target'], - 'code': int(value['code']), - 'masking': int(value['masking']), - 'query': int(value['query']), - } - ) - - @property - def path(self): - return self['path'] - - @path.setter - def path(self, value): - self['path'] = value - - @property - def target(self): - return self['target'] - - @target.setter - def target(self, value): - self['target'] = value - - @property - def code(self): - return self['code'] - - @code.setter - def code(self, value): - self['code'] = value - - @property - def masking(self): - return self['masking'] - - @masking.setter - def masking(self, value): - self['masking'] = value - - @property - def query(self): - return self['query'] - - @query.setter - def query(self, value): - self['query'] = value - - def _equality_tuple(self): - return (self.path, self.target, self.code, self.masking, self.query) - - def __hash__(self): - return hash( - (self.path, self.target, self.code, self.masking, self.query) - ) - - def __repr__(self): - return f'"{self.path}" "{self.target}" {self.code} {self.masking} {self.query}' - - -class UrlfwdRecord(ValuesMixin, Record): - _type = 'URLFWD' - _value_type = UrlfwdValue - - -Record.register_type(UrlfwdRecord) diff --git a/octodns/record/caa.py b/octodns/record/caa.py new file mode 100644 index 0000000..02e17cb --- /dev/null +++ b/octodns/record/caa.py @@ -0,0 +1,101 @@ +# +# +# + +from ..equality import EqualityTupleMixin +from .base import Record, ValuesMixin +from .rr import RrParseError + + +class CaaValue(EqualityTupleMixin, dict): + # https://tools.ietf.org/html/rfc6844#page-5 + + @classmethod + def parse_rdata_text(cls, value): + try: + flags, tag, value = value.split(' ') + except ValueError: + raise RrParseError() + try: + flags = int(flags) + except ValueError: + pass + return {'flags': flags, 'tag': tag, 'value': value} + + @classmethod + def validate(cls, data, _type): + if not isinstance(data, (list, tuple)): + data = (data,) + reasons = [] + for value in data: + try: + flags = int(value.get('flags', 0)) + if flags < 0 or flags > 255: + reasons.append(f'invalid flags "{flags}"') + except ValueError: + reasons.append(f'invalid flags "{value["flags"]}"') + + if 'tag' not in value: + reasons.append('missing tag') + if 'value' not in value: + reasons.append('missing value') + return reasons + + @classmethod + def process(cls, values): + return [cls(v) for v in values] + + def __init__(self, value): + super().__init__( + { + 'flags': int(value.get('flags', 0)), + 'tag': value['tag'], + 'value': value['value'], + } + ) + + @property + def flags(self): + return self['flags'] + + @flags.setter + def flags(self, value): + self['flags'] = value + + @property + def tag(self): + return self['tag'] + + @tag.setter + def tag(self, value): + self['tag'] = value + + @property + def value(self): + return self['value'] + + @value.setter + def value(self, value): + self['value'] = value + + @property + def data(self): + return self + + @property + def rdata_text(self): + return f'{self.flags} {self.tag} {self.value}' + + def _equality_tuple(self): + return (self.flags, self.tag, self.value) + + def __repr__(self): + return f'{self.flags} {self.tag} "{self.value}"' + + +class CaaRecord(ValuesMixin, Record): + _type = 'CAA' + _value_type = CaaValue + + +Record.register_type(CaaRecord) diff --git a/octodns/record/chunked.py b/octodns/record/chunked.py new file mode 100644 index 0000000..d944342 --- /dev/null +++ b/octodns/record/chunked.py @@ -0,0 +1,83 @@ +# +# +# + +from .base import Record, ValuesMixin +import re + + +class _ChunkedValuesMixin(ValuesMixin): + CHUNK_SIZE = 255 + _unescaped_semicolon_re = re.compile(r'\w;') + + def chunked_value(self, value): + value = value.replace('"', '\\"') + vs = [ + value[i : i + self.CHUNK_SIZE] + for i in range(0, len(value), self.CHUNK_SIZE) + ] + vs = '" "'.join(vs) + return f'"{vs}"' + + @property + def chunked_values(self): + values = [] + for v in self.values: + values.append(self.chunked_value(v)) + return values + + +class _ChunkedValue(str): + _unescaped_semicolon_re = re.compile(r'\w;') + + @classmethod + def parse_rdata_text(cls, value): + try: + return value.replace(';', '\\;') + except AttributeError: + return value + + @classmethod + def validate(cls, data, _type): + if not data: + return ['missing value(s)'] + elif not isinstance(data, (list, tuple)): + data = (data,) + reasons = [] + for value in data: + if cls._unescaped_semicolon_re.search(value): + reasons.append(f'unescaped ; in "{value}"') + return reasons + + @classmethod + def process(cls, values): + ret = [] + for v in values: + if v and v[0] == '"': + v = v[1:-1] + ret.append(cls(v.replace('" "', ''))) + return ret + + @property + def rdata_text(self): + return self + + +class SpfRecord(_ChunkedValuesMixin, Record): + _type = 'SPF' + _value_type = _ChunkedValue + + +Record.register_type(SpfRecord) + + +class TxtValue(_ChunkedValue): + pass + + +class TxtRecord(_ChunkedValuesMixin, Record): + _type = 'TXT' + _value_type = TxtValue + + +Record.register_type(TxtRecord) diff --git a/octodns/record/ds.py b/octodns/record/ds.py new file mode 100644 index 0000000..ad431f2 --- /dev/null +++ b/octodns/record/ds.py @@ -0,0 +1,136 @@ +# +# +# + +from ..equality import EqualityTupleMixin +from .base import Record, ValuesMixin +from .rr import RrParseError + + +class DsValue(EqualityTupleMixin, dict): + # https://www.rfc-editor.org/rfc/rfc4034.html#section-2.1 + + @classmethod + def parse_rdata_text(cls, value): + try: + flags, protocol, algorithm, public_key = value.split(' ') + except ValueError: + raise RrParseError() + try: + flags = int(flags) + except ValueError: + pass + try: + protocol = int(protocol) + except ValueError: + pass + try: + algorithm = int(algorithm) + except ValueError: + pass + return { + 'flags': flags, + 'protocol': protocol, + 'algorithm': algorithm, + 'public_key': public_key, + } + + @classmethod + def validate(cls, data, _type): + if not isinstance(data, (list, tuple)): + data = (data,) + reasons = [] + for value in data: + try: + int(value['flags']) + except KeyError: + reasons.append('missing flags') + except ValueError: + reasons.append(f'invalid flags "{value["flags"]}"') + try: + int(value['protocol']) + except KeyError: + reasons.append('missing protocol') + except ValueError: + reasons.append(f'invalid protocol "{value["protocol"]}"') + try: + int(value['algorithm']) + except KeyError: + reasons.append('missing algorithm') + except ValueError: + reasons.append(f'invalid algorithm "{value["algorithm"]}"') + if 'public_key' not in value: + reasons.append('missing public_key') + return reasons + + @classmethod + def process(cls, values): + return [cls(v) for v in values] + + def __init__(self, value): + super().__init__( + { + 'flags': int(value['flags']), + 'protocol': int(value['protocol']), + 'algorithm': int(value['algorithm']), + 'public_key': value['public_key'], + } + ) + + @property + def flags(self): + return self['flags'] + + @flags.setter + def flags(self, value): + self['flags'] = value + + @property + def protocol(self): + return self['protocol'] + + @protocol.setter + def protocol(self, value): + self['protocol'] = value + + @property + def algorithm(self): + return self['algorithm'] + + @algorithm.setter + def algorithm(self, value): + self['algorithm'] = value + + @property + def public_key(self): + return self['public_key'] + + @public_key.setter + def public_key(self, value): + self['public_key'] = value + + @property + def data(self): + return self + + @property + def rdata_text(self): + return ( + f'{self.flags} {self.protocol} {self.algorithm} {self.public_key}' + ) + + def _equality_tuple(self): + return (self.flags, self.protocol, self.algorithm, self.public_key) + + def __repr__(self): + return ( + f'{self.flags} {self.protocol} {self.algorithm} {self.public_key}' + ) + + +class DsRecord(ValuesMixin, Record): + _type = 'DS' + _value_type = DsValue + + +Record.register_type(DsRecord) diff --git a/octodns/record/loc.py b/octodns/record/loc.py new file mode 100644 index 0000000..d9b06cd --- /dev/null +++ b/octodns/record/loc.py @@ -0,0 +1,358 @@ +# +# +# + +from ..equality import EqualityTupleMixin +from .base import Record, ValuesMixin +from .rr import RrParseError + + +class LocValue(EqualityTupleMixin, dict): + # TODO: this does not really match the RFC, but it's stuck using the details + # of how the type was impelemented. Would be nice to rework things to match + # while maintaining backwards compatibility. + # https://www.rfc-editor.org/rfc/rfc1876.html + + @classmethod + def parse_rdata_text(cls, value): + try: + value = value.replace('m', '') + ( + lat_degrees, + lat_minutes, + lat_seconds, + lat_direction, + long_degrees, + long_minutes, + long_seconds, + long_direction, + altitude, + size, + precision_horz, + precision_vert, + ) = value.split(' ') + except ValueError: + raise RrParseError() + try: + lat_degrees = int(lat_degrees) + except ValueError: + pass + try: + lat_minutes = int(lat_minutes) + except ValueError: + pass + try: + long_degrees = int(long_degrees) + except ValueError: + pass + try: + long_minutes = int(long_minutes) + except ValueError: + pass + try: + lat_seconds = float(lat_seconds) + except ValueError: + pass + try: + long_seconds = float(long_seconds) + except ValueError: + pass + try: + altitude = float(altitude) + except ValueError: + pass + try: + size = float(size) + except ValueError: + pass + try: + precision_horz = float(precision_horz) + except ValueError: + pass + try: + precision_vert = float(precision_vert) + except ValueError: + pass + return { + 'lat_degrees': lat_degrees, + 'lat_minutes': lat_minutes, + 'lat_seconds': lat_seconds, + 'lat_direction': lat_direction, + 'long_degrees': long_degrees, + 'long_minutes': long_minutes, + 'long_seconds': long_seconds, + 'long_direction': long_direction, + 'altitude': altitude, + 'size': size, + 'precision_horz': precision_horz, + 'precision_vert': precision_vert, + } + + @classmethod + def validate(cls, data, _type): + int_keys = [ + 'lat_degrees', + 'lat_minutes', + 'long_degrees', + 'long_minutes', + ] + + float_keys = [ + 'lat_seconds', + 'long_seconds', + 'altitude', + 'size', + 'precision_horz', + 'precision_vert', + ] + + direction_keys = ['lat_direction', 'long_direction'] + + if not isinstance(data, (list, tuple)): + data = (data,) + reasons = [] + for value in data: + for key in int_keys: + try: + int(value[key]) + if ( + ( + key == 'lat_degrees' + and not 0 <= int(value[key]) <= 90 + ) + or ( + key == 'long_degrees' + and not 0 <= int(value[key]) <= 180 + ) + or ( + key in ['lat_minutes', 'long_minutes'] + and not 0 <= int(value[key]) <= 59 + ) + ): + reasons.append( + f'invalid value for {key} ' f'"{value[key]}"' + ) + except KeyError: + reasons.append(f'missing {key}') + except ValueError: + reasons.append(f'invalid {key} "{value[key]}"') + + for key in float_keys: + try: + float(value[key]) + if ( + ( + key in ['lat_seconds', 'long_seconds'] + and not 0 <= float(value[key]) <= 59.999 + ) + or ( + key == 'altitude' + and not -100000.00 + <= float(value[key]) + <= 42849672.95 + ) + or ( + key in ['size', 'precision_horz', 'precision_vert'] + and not 0 <= float(value[key]) <= 90000000.00 + ) + ): + reasons.append( + f'invalid value for {key} ' f'"{value[key]}"' + ) + except KeyError: + reasons.append(f'missing {key}') + except ValueError: + reasons.append(f'invalid {key} "{value[key]}"') + + for key in direction_keys: + try: + str(value[key]) + if key == 'lat_direction' and value[key] not in ['N', 'S']: + reasons.append( + f'invalid direction for {key} ' f'"{value[key]}"' + ) + if key == 'long_direction' and value[key] not in ['E', 'W']: + reasons.append( + f'invalid direction for {key} ' f'"{value[key]}"' + ) + except KeyError: + reasons.append(f'missing {key}') + return reasons + + @classmethod + def process(cls, values): + return [cls(v) for v in values] + + def __init__(self, value): + super().__init__( + { + 'lat_degrees': int(value['lat_degrees']), + 'lat_minutes': int(value['lat_minutes']), + 'lat_seconds': float(value['lat_seconds']), + 'lat_direction': value['lat_direction'].upper(), + 'long_degrees': int(value['long_degrees']), + 'long_minutes': int(value['long_minutes']), + 'long_seconds': float(value['long_seconds']), + 'long_direction': value['long_direction'].upper(), + 'altitude': float(value['altitude']), + 'size': float(value['size']), + 'precision_horz': float(value['precision_horz']), + 'precision_vert': float(value['precision_vert']), + } + ) + + @property + def lat_degrees(self): + return self['lat_degrees'] + + @lat_degrees.setter + def lat_degrees(self, value): + self['lat_degrees'] = value + + @property + def lat_minutes(self): + return self['lat_minutes'] + + @lat_minutes.setter + def lat_minutes(self, value): + self['lat_minutes'] = value + + @property + def lat_seconds(self): + return self['lat_seconds'] + + @lat_seconds.setter + def lat_seconds(self, value): + self['lat_seconds'] = value + + @property + def lat_direction(self): + return self['lat_direction'] + + @lat_direction.setter + def lat_direction(self, value): + self['lat_direction'] = value + + @property + def long_degrees(self): + return self['long_degrees'] + + @long_degrees.setter + def long_degrees(self, value): + self['long_degrees'] = value + + @property + def long_minutes(self): + return self['long_minutes'] + + @long_minutes.setter + def long_minutes(self, value): + self['long_minutes'] = value + + @property + def long_seconds(self): + return self['long_seconds'] + + @long_seconds.setter + def long_seconds(self, value): + self['long_seconds'] = value + + @property + def long_direction(self): + return self['long_direction'] + + @long_direction.setter + def long_direction(self, value): + self['long_direction'] = value + + @property + def altitude(self): + return self['altitude'] + + @altitude.setter + def altitude(self, value): + self['altitude'] = value + + @property + def size(self): + return self['size'] + + @size.setter + def size(self, value): + self['size'] = value + + @property + def precision_horz(self): + return self['precision_horz'] + + @precision_horz.setter + def precision_horz(self, value): + self['precision_horz'] = value + + @property + def precision_vert(self): + return self['precision_vert'] + + @precision_vert.setter + def precision_vert(self, value): + self['precision_vert'] = value + + @property + def data(self): + return self + + @property + def rdata_text(self): + return f'{self.lat_degrees} {self.lat_minutes} {self.lat_seconds} {self.lat_direction} {self.long_degrees} {self.long_minutes} {self.long_seconds} {self.long_direction} {self.altitude}m {self.size}m {self.precision_horz}m {self.precision_vert}m' + + def __hash__(self): + return hash( + ( + self.lat_degrees, + self.lat_minutes, + self.lat_seconds, + self.lat_direction, + self.long_degrees, + self.long_minutes, + self.long_seconds, + self.long_direction, + self.altitude, + self.size, + self.precision_horz, + self.precision_vert, + ) + ) + + def _equality_tuple(self): + return ( + self.lat_degrees, + self.lat_minutes, + self.lat_seconds, + self.lat_direction, + self.long_degrees, + self.long_minutes, + self.long_seconds, + self.long_direction, + self.altitude, + self.size, + self.precision_horz, + self.precision_vert, + ) + + def __repr__(self): + return ( + f"'{self.lat_degrees} {self.lat_minutes} " + f"{self.lat_seconds:.3f} {self.lat_direction} " + f"{self.long_degrees} {self.long_minutes} " + f"{self.long_seconds:.3f} {self.long_direction} " + f"{self.altitude:.2f}m {self.size:.2f}m " + f"{self.precision_horz:.2f}m {self.precision_vert:.2f}m'" + ) + + +class LocRecord(ValuesMixin, Record): + _type = 'LOC' + _value_type = LocValue + + +Record.register_type(LocRecord) diff --git a/octodns/record/mx.py b/octodns/record/mx.py new file mode 100644 index 0000000..77d34a7 --- /dev/null +++ b/octodns/record/mx.py @@ -0,0 +1,120 @@ +# +# +# + +from fqdn import FQDN + +from ..equality import EqualityTupleMixin +from ..idna import idna_encode +from .base import Record, ValuesMixin +from .rr import RrParseError + + +class MxValue(EqualityTupleMixin, dict): + @classmethod + def parse_rdata_text(cls, value): + try: + preference, exchange = value.split(' ') + except ValueError: + raise RrParseError() + try: + preference = int(preference) + except ValueError: + pass + return {'preference': preference, 'exchange': exchange} + + @classmethod + def validate(cls, data, _type): + if not isinstance(data, (list, tuple)): + data = (data,) + reasons = [] + for value in data: + try: + try: + int(value['preference']) + except KeyError: + int(value['priority']) + except KeyError: + reasons.append('missing preference') + except ValueError: + reasons.append(f'invalid preference "{value["preference"]}"') + exchange = None + try: + exchange = value.get('exchange', None) or value['value'] + if not exchange: + reasons.append('missing exchange') + continue + exchange = idna_encode(exchange) + if ( + exchange != '.' + and not FQDN(exchange, allow_underscores=True).is_valid + ): + reasons.append( + f'Invalid MX exchange "{exchange}" is not ' + 'a valid FQDN.' + ) + elif not exchange.endswith('.'): + reasons.append(f'MX value "{exchange}" missing trailing .') + except KeyError: + reasons.append('missing exchange') + return reasons + + @classmethod + def process(cls, values): + return [cls(v) for v in values] + + def __init__(self, value): + # RFC1035 says preference, half the providers use priority + try: + preference = value['preference'] + except KeyError: + preference = value['priority'] + # UNTIL 1.0 remove value fallback + try: + exchange = value['exchange'] + except KeyError: + exchange = value['value'] + super().__init__( + {'preference': int(preference), 'exchange': idna_encode(exchange)} + ) + + @property + def preference(self): + return self['preference'] + + @preference.setter + def preference(self, value): + self['preference'] = value + + @property + def exchange(self): + return self['exchange'] + + @exchange.setter + def exchange(self, value): + self['exchange'] = value + + @property + def data(self): + return self + + @property + def rdata_text(self): + return f'{self.preference} {self.exchange}' + + def __hash__(self): + return hash((self.preference, self.exchange)) + + def _equality_tuple(self): + return (self.preference, self.exchange) + + def __repr__(self): + return f"'{self.preference} {self.exchange}'" + + +class MxRecord(ValuesMixin, Record): + _type = 'MX' + _value_type = MxValue + + +Record.register_type(MxRecord) diff --git a/octodns/record/naptr.py b/octodns/record/naptr.py new file mode 100644 index 0000000..07b4fc0 --- /dev/null +++ b/octodns/record/naptr.py @@ -0,0 +1,172 @@ +# +# +# + +from ..equality import EqualityTupleMixin +from .base import Record, ValuesMixin +from .rr import RrParseError + + +class NaptrValue(EqualityTupleMixin, dict): + VALID_FLAGS = ('S', 'A', 'U', 'P') + + @classmethod + def parse_rdata_text(cls, value): + try: + ( + order, + preference, + flags, + service, + regexp, + replacement, + ) = value.split(' ') + except ValueError: + raise RrParseError() + try: + order = int(order) + preference = int(preference) + except ValueError: + pass + return { + 'order': order, + 'preference': preference, + 'flags': flags, + 'service': service, + 'regexp': regexp, + 'replacement': replacement, + } + + @classmethod + def validate(cls, data, _type): + if not isinstance(data, (list, tuple)): + data = (data,) + reasons = [] + for value in data: + try: + int(value['order']) + except KeyError: + reasons.append('missing order') + except ValueError: + reasons.append(f'invalid order "{value["order"]}"') + try: + int(value['preference']) + except KeyError: + reasons.append('missing preference') + except ValueError: + reasons.append(f'invalid preference "{value["preference"]}"') + try: + flags = value['flags'] + if flags not in cls.VALID_FLAGS: + reasons.append(f'unrecognized flags "{flags}"') + except KeyError: + reasons.append('missing flags') + + # TODO: validate these... they're non-trivial + for k in ('service', 'regexp', 'replacement'): + if k not in value: + reasons.append(f'missing {k}') + + return reasons + + @classmethod + def process(cls, values): + return [cls(v) for v in values] + + def __init__(self, value): + super().__init__( + { + 'order': int(value['order']), + 'preference': int(value['preference']), + 'flags': value['flags'], + 'service': value['service'], + 'regexp': value['regexp'], + 'replacement': value['replacement'], + } + ) + + @property + def order(self): + return self['order'] + + @order.setter + def order(self, value): + self['order'] = value + + @property + def preference(self): + return self['preference'] + + @preference.setter + def preference(self, value): + self['preference'] = value + + @property + def flags(self): + return self['flags'] + + @flags.setter + def flags(self, value): + self['flags'] = value + + @property + def service(self): + return self['service'] + + @service.setter + def service(self, value): + self['service'] = value + + @property + def regexp(self): + return self['regexp'] + + @regexp.setter + def regexp(self, value): + self['regexp'] = value + + @property + def replacement(self): + return self['replacement'] + + @replacement.setter + def replacement(self, value): + self['replacement'] = value + + @property + def data(self): + return self + + @property + def rdata_text(self): + return f'{self.order} {self.preference} {self.flags} {self.service} {self.regexp} {self.replacement}' + + def __hash__(self): + return hash(self.__repr__()) + + def _equality_tuple(self): + return ( + self.order, + self.preference, + self.flags, + self.service, + self.regexp, + self.replacement, + ) + + def __repr__(self): + flags = self.flags if self.flags is not None else '' + service = self.service if self.service is not None else '' + regexp = self.regexp if self.regexp is not None else '' + return ( + f"'{self.order} {self.preference} \"{flags}\" \"{service}\" " + f"\"{regexp}\" {self.replacement}'" + ) + + +class NaptrRecord(ValuesMixin, Record): + _type = 'NAPTR' + _value_type = NaptrValue + + +Record.register_type(NaptrRecord) diff --git a/octodns/record/srv.py b/octodns/record/srv.py new file mode 100644 index 0000000..b312bc6 --- /dev/null +++ b/octodns/record/srv.py @@ -0,0 +1,158 @@ +# +# +# + +from fqdn import FQDN +import re + +from ..equality import EqualityTupleMixin +from ..idna import idna_encode +from .base import Record, ValuesMixin +from .rr import RrParseError + + +class SrvValue(EqualityTupleMixin, dict): + @classmethod + def parse_rdata_text(self, value): + try: + priority, weight, port, target = value.split(' ') + except ValueError: + raise RrParseError() + try: + priority = int(priority) + except ValueError: + pass + try: + weight = int(weight) + except ValueError: + pass + try: + port = int(port) + except ValueError: + pass + return { + 'priority': priority, + 'weight': weight, + 'port': port, + 'target': target, + } + + @classmethod + def validate(cls, data, _type): + if not isinstance(data, (list, tuple)): + data = (data,) + reasons = [] + for value in data: + # TODO: validate algorithm and fingerprint_type values + try: + int(value['priority']) + except KeyError: + reasons.append('missing priority') + except ValueError: + reasons.append(f'invalid priority "{value["priority"]}"') + try: + int(value['weight']) + except KeyError: + reasons.append('missing weight') + except ValueError: + reasons.append(f'invalid weight "{value["weight"]}"') + try: + int(value['port']) + except KeyError: + reasons.append('missing port') + except ValueError: + reasons.append(f'invalid port "{value["port"]}"') + try: + target = value['target'] + if not target: + reasons.append('missing target') + continue + target = idna_encode(target) + if not target.endswith('.'): + reasons.append(f'SRV value "{target}" missing trailing .') + if ( + target != '.' + and not FQDN(target, allow_underscores=True).is_valid + ): + reasons.append( + f'Invalid SRV target "{target}" is not a valid FQDN.' + ) + except KeyError: + reasons.append('missing target') + return reasons + + @classmethod + def process(cls, values): + return [cls(v) for v in values] + + def __init__(self, value): + super().__init__( + { + 'priority': int(value['priority']), + 'weight': int(value['weight']), + 'port': int(value['port']), + 'target': idna_encode(value['target']), + } + ) + + @property + def priority(self): + return self['priority'] + + @priority.setter + def priority(self, value): + self['priority'] = value + + @property + def weight(self): + return self['weight'] + + @weight.setter + def weight(self, value): + self['weight'] = value + + @property + def port(self): + return self['port'] + + @port.setter + def port(self, value): + self['port'] = value + + @property + def target(self): + return self['target'] + + @target.setter + def target(self, value): + self['target'] = value + + @property + def data(self): + return self + + def __hash__(self): + return hash(self.__repr__()) + + def _equality_tuple(self): + return (self.priority, self.weight, self.port, self.target) + + def __repr__(self): + return f"'{self.priority} {self.weight} {self.port} {self.target}'" + + +class SrvRecord(ValuesMixin, Record): + _type = 'SRV' + _value_type = SrvValue + _name_re = re.compile(r'^(\*|_[^\.]+)\.[^\.]+') + + @classmethod + def validate(cls, name, fqdn, data): + reasons = [] + if not cls._name_re.match(name): + reasons.append('invalid name for SRV record') + reasons.extend(super().validate(name, fqdn, data)) + return reasons + + +Record.register_type(SrvRecord) diff --git a/octodns/record/sshfp.py b/octodns/record/sshfp.py new file mode 100644 index 0000000..b3234df --- /dev/null +++ b/octodns/record/sshfp.py @@ -0,0 +1,124 @@ +# +# +# + +from ..equality import EqualityTupleMixin +from .base import Record, ValuesMixin +from .rr import RrParseError + + +class SshfpValue(EqualityTupleMixin, dict): + VALID_ALGORITHMS = (1, 2, 3, 4) + VALID_FINGERPRINT_TYPES = (1, 2) + + @classmethod + def parse_rdata_text(self, value): + try: + algorithm, fingerprint_type, fingerprint = value.split(' ') + except ValueError: + raise RrParseError() + try: + algorithm = int(algorithm) + except ValueError: + pass + try: + fingerprint_type = int(fingerprint_type) + except ValueError: + pass + return { + 'algorithm': algorithm, + 'fingerprint_type': fingerprint_type, + 'fingerprint': fingerprint, + } + + @classmethod + def validate(cls, data, _type): + if not isinstance(data, (list, tuple)): + data = (data,) + reasons = [] + for value in data: + try: + algorithm = int(value['algorithm']) + if algorithm not in cls.VALID_ALGORITHMS: + reasons.append(f'unrecognized algorithm "{algorithm}"') + except KeyError: + reasons.append('missing algorithm') + except ValueError: + reasons.append(f'invalid algorithm "{value["algorithm"]}"') + try: + fingerprint_type = int(value['fingerprint_type']) + if fingerprint_type not in cls.VALID_FINGERPRINT_TYPES: + reasons.append( + 'unrecognized fingerprint_type ' f'"{fingerprint_type}"' + ) + except KeyError: + reasons.append('missing fingerprint_type') + except ValueError: + reasons.append( + 'invalid fingerprint_type ' f'"{value["fingerprint_type"]}"' + ) + if 'fingerprint' not in value: + reasons.append('missing fingerprint') + return reasons + + @classmethod + def process(cls, values): + return [cls(v) for v in values] + + def __init__(self, value): + super().__init__( + { + 'algorithm': int(value['algorithm']), + 'fingerprint_type': int(value['fingerprint_type']), + 'fingerprint': value['fingerprint'], + } + ) + + @property + def algorithm(self): + return self['algorithm'] + + @algorithm.setter + def algorithm(self, value): + self['algorithm'] = value + + @property + def fingerprint_type(self): + return self['fingerprint_type'] + + @fingerprint_type.setter + def fingerprint_type(self, value): + self['fingerprint_type'] = value + + @property + def fingerprint(self): + return self['fingerprint'] + + @fingerprint.setter + def fingerprint(self, value): + self['fingerprint'] = value + + @property + def data(self): + return self + + @property + def rdata_text(self): + return f'{self.algorithm} {self.fingerprint_type} {self.fingerprint}' + + def __hash__(self): + return hash(self.__repr__()) + + def _equality_tuple(self): + return (self.algorithm, self.fingerprint_type, self.fingerprint) + + def __repr__(self): + return f"'{self.algorithm} {self.fingerprint_type} {self.fingerprint}'" + + +class SshfpRecord(ValuesMixin, Record): + _type = 'SSHFP' + _value_type = SshfpValue + + +Record.register_type(SshfpRecord) diff --git a/octodns/record/tlsa.py b/octodns/record/tlsa.py new file mode 100644 index 0000000..1fa463a --- /dev/null +++ b/octodns/record/tlsa.py @@ -0,0 +1,160 @@ +# +# +# + +from ..equality import EqualityTupleMixin +from .base import Record, ValuesMixin +from .rr import RrParseError + + +class TlsaValue(EqualityTupleMixin, dict): + @classmethod + def parse_rdata_text(self, value): + try: + ( + certificate_usage, + selector, + matching_type, + certificate_association_data, + ) = value.split(' ') + except ValueError: + raise RrParseError() + try: + certificate_usage = int(certificate_usage) + except ValueError: + pass + try: + selector = int(selector) + except ValueError: + pass + try: + matching_type = int(matching_type) + except ValueError: + pass + return { + 'certificate_usage': certificate_usage, + 'selector': selector, + 'matching_type': matching_type, + 'certificate_association_data': certificate_association_data, + } + + @classmethod + def validate(cls, data, _type): + if not isinstance(data, (list, tuple)): + data = (data,) + reasons = [] + for value in data: + try: + certificate_usage = int(value.get('certificate_usage', 0)) + if certificate_usage < 0 or certificate_usage > 3: + reasons.append( + f'invalid certificate_usage ' f'"{certificate_usage}"' + ) + except ValueError: + reasons.append( + f'invalid certificate_usage ' + f'"{value["certificate_usage"]}"' + ) + + try: + selector = int(value.get('selector', 0)) + if selector < 0 or selector > 1: + reasons.append(f'invalid selector "{selector}"') + except ValueError: + reasons.append(f'invalid selector "{value["selector"]}"') + + try: + matching_type = int(value.get('matching_type', 0)) + if matching_type < 0 or matching_type > 2: + reasons.append(f'invalid matching_type "{matching_type}"') + except ValueError: + reasons.append( + f'invalid matching_type ' f'"{value["matching_type"]}"' + ) + + if 'certificate_usage' not in value: + reasons.append('missing certificate_usage') + if 'selector' not in value: + reasons.append('missing selector') + if 'matching_type' not in value: + reasons.append('missing matching_type') + if 'certificate_association_data' not in value: + reasons.append('missing certificate_association_data') + return reasons + + @classmethod + def process(cls, values): + return [cls(v) for v in values] + + def __init__(self, value): + super().__init__( + { + 'certificate_usage': int(value.get('certificate_usage', 0)), + 'selector': int(value.get('selector', 0)), + 'matching_type': int(value.get('matching_type', 0)), + # force it to a string, in case the hex has only numerical + # values and it was converted to an int at some point + # TODO: this needed on any others? + 'certificate_association_data': str( + value['certificate_association_data'] + ), + } + ) + + @property + def certificate_usage(self): + return self['certificate_usage'] + + @certificate_usage.setter + def certificate_usage(self, value): + self['certificate_usage'] = value + + @property + def selector(self): + return self['selector'] + + @selector.setter + def selector(self, value): + self['selector'] = value + + @property + def matching_type(self): + return self['matching_type'] + + @matching_type.setter + def matching_type(self, value): + self['matching_type'] = value + + @property + def certificate_association_data(self): + return self['certificate_association_data'] + + @certificate_association_data.setter + def certificate_association_data(self, value): + self['certificate_association_data'] = value + + @property + def rdata_text(self): + return f'{self.certificate_usage} {self.selector} {self.matching_type} {self.certificate_association_data}' + + def _equality_tuple(self): + return ( + self.certificate_usage, + self.selector, + self.matching_type, + self.certificate_association_data, + ) + + def __repr__(self): + return ( + f"'{self.certificate_usage} {self.selector} '" + f"'{self.matching_type} {self.certificate_association_data}'" + ) + + +class TlsaRecord(ValuesMixin, Record): + _type = 'TLSA' + _value_type = TlsaValue + + +Record.register_type(TlsaRecord) diff --git a/octodns/record/url.py b/octodns/record/url.py new file mode 100644 index 0000000..04f9f6a --- /dev/null +++ b/octodns/record/url.py @@ -0,0 +1,121 @@ +# +# +# + +from ..equality import EqualityTupleMixin +from .base import Record, ValuesMixin + + +class UrlfwdValue(EqualityTupleMixin, dict): + VALID_CODES = (301, 302) + VALID_MASKS = (0, 1, 2) + VALID_QUERY = (0, 1) + + @classmethod + def validate(cls, data, _type): + if not isinstance(data, (list, tuple)): + data = (data,) + reasons = [] + for value in data: + try: + code = int(value['code']) + if code not in cls.VALID_CODES: + reasons.append(f'unrecognized return code "{code}"') + except KeyError: + reasons.append('missing code') + except ValueError: + reasons.append(f'invalid return code "{value["code"]}"') + try: + masking = int(value['masking']) + if masking not in cls.VALID_MASKS: + reasons.append(f'unrecognized masking setting "{masking}"') + except KeyError: + reasons.append('missing masking') + except ValueError: + reasons.append(f'invalid masking setting "{value["masking"]}"') + try: + query = int(value['query']) + if query not in cls.VALID_QUERY: + reasons.append(f'unrecognized query setting "{query}"') + except KeyError: + reasons.append('missing query') + except ValueError: + reasons.append(f'invalid query setting "{value["query"]}"') + for k in ('path', 'target'): + if k not in value: + reasons.append(f'missing {k}') + return reasons + + @classmethod + def process(cls, values): + return [cls(v) for v in values] + + def __init__(self, value): + super().__init__( + { + 'path': value['path'], + 'target': value['target'], + 'code': int(value['code']), + 'masking': int(value['masking']), + 'query': int(value['query']), + } + ) + + @property + def path(self): + return self['path'] + + @path.setter + def path(self, value): + self['path'] = value + + @property + def target(self): + return self['target'] + + @target.setter + def target(self, value): + self['target'] = value + + @property + def code(self): + return self['code'] + + @code.setter + def code(self, value): + self['code'] = value + + @property + def masking(self): + return self['masking'] + + @masking.setter + def masking(self, value): + self['masking'] = value + + @property + def query(self): + return self['query'] + + @query.setter + def query(self, value): + self['query'] = value + + def _equality_tuple(self): + return (self.path, self.target, self.code, self.masking, self.query) + + def __hash__(self): + return hash( + (self.path, self.target, self.code, self.masking, self.query) + ) + + def __repr__(self): + return f'"{self.path}" "{self.target}" {self.code} {self.masking} {self.query}' + + +class UrlfwdRecord(ValuesMixin, Record): + _type = 'URLFWD' + _value_type = UrlfwdValue + + +Record.register_type(UrlfwdRecord) diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index 51c9ad7..c5dcb1d 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -46,8 +46,8 @@ from octodns.record import ( UrlfwdValue, ValidationError, ValuesMixin, - _ChunkedValue, ) +from octodns.record.chunked import _ChunkedValue from octodns.record.dynamic import _Dynamic, _DynamicPool, _DynamicRule from octodns.record.target import _TargetValue from octodns.zone import Zone From 9205e32392069d6314bf383b2cc35e1c1993be1d Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Mon, 2 Jan 2023 14:58:03 -0500 Subject: [PATCH 4/5] Finish breaking up octodns.record --- octodns/record/__init__.py | 31 +++++----- octodns/record/a.py | 26 ++++++++ octodns/record/aaaa.py | 26 ++++++++ octodns/record/alias.py | 26 ++++++++ octodns/record/chunked.py | 22 +------ octodns/record/cname.py | 27 +++++++++ octodns/record/dname.py | 19 ++++++ octodns/record/{ipaddress.py => ip.py} | 33 +--------- octodns/record/ns.py | 18 ++++++ octodns/record/ptr.py | 24 ++++++++ octodns/record/spf.py | 14 +++++ octodns/record/target.py | 84 -------------------------- octodns/record/txt.py | 18 ++++++ tests/test_octodns_record.py | 2 +- 14 files changed, 216 insertions(+), 154 deletions(-) create mode 100644 octodns/record/a.py create mode 100644 octodns/record/aaaa.py create mode 100644 octodns/record/alias.py create mode 100644 octodns/record/cname.py create mode 100644 octodns/record/dname.py rename octodns/record/{ipaddress.py => ip.py} (67%) create mode 100644 octodns/record/ns.py create mode 100644 octodns/record/ptr.py create mode 100644 octodns/record/spf.py create mode 100644 octodns/record/txt.py diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index 8f16484..bd65b06 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -3,34 +3,29 @@ # +from .a import ARecord, Ipv4Address, Ipv4Value +from .aaaa import AaaaRecord, Ipv6Address, Ipv6Value +from .alias import AliasRecord, AliasValue from .base import Record, ValueMixin, ValuesMixin -from .change import Change, Create, Delete, Update from .caa import CaaRecord, CaaValue +from .change import Change, Create, Delete, Update +from .cname import CnameRecord, CnameValue +from .dname import DnameRecord, DnameValue from .ds import DsRecord, DsValue from .exception import RecordException, ValidationError from .geo import GeoCodes, GeoValue from .loc import LocRecord, LocValue from .mx import MxRecord, MxValue from .naptr import NaptrRecord, NaptrValue +from .ns import NsValue, NsRecord +from .ptr import PtrValue, PtrRecord from .rr import Rr, RrParseError -from .tlsa import TlsaRecord, TlsaValue -from .url import UrlfwdRecord, UrlfwdValue +from .spf import SpfRecord from .srv import SrvRecord, SrvValue from .sshfp import SshfpRecord, SshfpValue -from .target import ( - AliasRecord, - AliasValue, - CnameRecord, - CnameValue, - DnameRecord, - DnameValue, - NsValue, - NsRecord, - PtrValue, - PtrRecord, -) -from .ipaddress import ARecord, AaaaRecord, Ipv4Address, Ipv6Address -from .chunked import SpfRecord, TxtValue, TxtRecord +from .tlsa import TlsaRecord, TlsaValue +from .txt import TxtValue, TxtRecord +from .url import UrlfwdRecord, UrlfwdValue # quell warnings ARecord @@ -51,7 +46,9 @@ DsValue GeoCodes GeoValue Ipv4Address +Ipv4Value Ipv6Address +Ipv6Value LocRecord LocValue MxRecord diff --git a/octodns/record/a.py b/octodns/record/a.py new file mode 100644 index 0000000..313f53c --- /dev/null +++ b/octodns/record/a.py @@ -0,0 +1,26 @@ +# +# +# + +from ipaddress import IPv4Address as _IPv4Address + +from .base import Record +from .dynamic import _DynamicMixin +from .geo import _GeoMixin +from .ip import _IpValue + + +class Ipv4Value(_IpValue): + _address_type = _IPv4Address + _address_name = 'IPv4' + + +Ipv4Address = Ipv4Value + + +class ARecord(_DynamicMixin, _GeoMixin, Record): + _type = 'A' + _value_type = Ipv4Value + + +Record.register_type(ARecord) diff --git a/octodns/record/aaaa.py b/octodns/record/aaaa.py new file mode 100644 index 0000000..3d0a3e4 --- /dev/null +++ b/octodns/record/aaaa.py @@ -0,0 +1,26 @@ +# +# +# + +from ipaddress import IPv6Address as _IPv6Address + +from .base import Record +from .dynamic import _DynamicMixin +from .geo import _GeoMixin +from .ip import _IpValue + + +class Ipv6Value(_IpValue): + _address_type = _IPv6Address + _address_name = 'IPv6' + + +Ipv6Address = Ipv6Value + + +class AaaaRecord(_DynamicMixin, _GeoMixin, Record): + _type = 'AAAA' + _value_type = Ipv6Address + + +Record.register_type(AaaaRecord) diff --git a/octodns/record/alias.py b/octodns/record/alias.py new file mode 100644 index 0000000..4802591 --- /dev/null +++ b/octodns/record/alias.py @@ -0,0 +1,26 @@ +# +# +# + +from .base import Record, ValueMixin +from .target import _TargetValue + + +class AliasValue(_TargetValue): + pass + + +class AliasRecord(ValueMixin, Record): + _type = 'ALIAS' + _value_type = AliasValue + + @classmethod + def validate(cls, name, fqdn, data): + reasons = [] + if name != '': + reasons.append('non-root ALIAS not allowed') + reasons.extend(super().validate(name, fqdn, data)) + return reasons + + +Record.register_type(AliasRecord) diff --git a/octodns/record/chunked.py b/octodns/record/chunked.py index d944342..8f8009e 100644 --- a/octodns/record/chunked.py +++ b/octodns/record/chunked.py @@ -2,7 +2,7 @@ # # -from .base import Record, ValuesMixin +from .base import ValuesMixin import re @@ -61,23 +61,3 @@ class _ChunkedValue(str): @property def rdata_text(self): return self - - -class SpfRecord(_ChunkedValuesMixin, Record): - _type = 'SPF' - _value_type = _ChunkedValue - - -Record.register_type(SpfRecord) - - -class TxtValue(_ChunkedValue): - pass - - -class TxtRecord(_ChunkedValuesMixin, Record): - _type = 'TXT' - _value_type = TxtValue - - -Record.register_type(TxtRecord) diff --git a/octodns/record/cname.py b/octodns/record/cname.py new file mode 100644 index 0000000..268bda7 --- /dev/null +++ b/octodns/record/cname.py @@ -0,0 +1,27 @@ +# +# +# + +from .base import Record, ValueMixin +from .dynamic import _DynamicMixin +from .target import _TargetValue + + +class CnameValue(_TargetValue): + pass + + +class CnameRecord(_DynamicMixin, ValueMixin, Record): + _type = 'CNAME' + _value_type = CnameValue + + @classmethod + def validate(cls, name, fqdn, data): + reasons = [] + if name == '': + reasons.append('root CNAME not allowed') + reasons.extend(super().validate(name, fqdn, data)) + return reasons + + +Record.register_type(CnameRecord) diff --git a/octodns/record/dname.py b/octodns/record/dname.py new file mode 100644 index 0000000..43a4b9e --- /dev/null +++ b/octodns/record/dname.py @@ -0,0 +1,19 @@ +# +# +# + +from .base import Record, ValueMixin +from .dynamic import _DynamicMixin +from .target import _TargetValue + + +class DnameValue(_TargetValue): + pass + + +class DnameRecord(_DynamicMixin, ValueMixin, Record): + _type = 'DNAME' + _value_type = DnameValue + + +Record.register_type(DnameRecord) diff --git a/octodns/record/ipaddress.py b/octodns/record/ip.py similarity index 67% rename from octodns/record/ipaddress.py rename to octodns/record/ip.py index 3c2a44a..6b3fe3c 100644 --- a/octodns/record/ipaddress.py +++ b/octodns/record/ip.py @@ -2,14 +2,8 @@ # # -from ipaddress import IPv4Address as _IPv4Address, IPv6Address as _IPv6Address -from .base import Record -from .dynamic import _DynamicMixin -from .geo import _GeoMixin - - -class _IpAddress(str): +class _IpValue(str): @classmethod def parse_rdata_text(cls, value): return value @@ -52,27 +46,4 @@ class _IpAddress(str): return self -class Ipv4Address(_IpAddress): - _address_type = _IPv4Address - _address_name = 'IPv4' - - -class ARecord(_DynamicMixin, _GeoMixin, Record): - _type = 'A' - _value_type = Ipv4Address - - -Record.register_type(ARecord) - - -class Ipv6Address(_IpAddress): - _address_type = _IPv6Address - _address_name = 'IPv6' - - -class AaaaRecord(_DynamicMixin, _GeoMixin, Record): - _type = 'AAAA' - _value_type = Ipv6Address - - -Record.register_type(AaaaRecord) +_IpAddress = _IpValue diff --git a/octodns/record/ns.py b/octodns/record/ns.py new file mode 100644 index 0000000..1b7895e --- /dev/null +++ b/octodns/record/ns.py @@ -0,0 +1,18 @@ +# +# +# + +from .base import Record, ValuesMixin +from .target import _TargetsValue + + +class NsValue(_TargetsValue): + pass + + +class NsRecord(ValuesMixin, Record): + _type = 'NS' + _value_type = NsValue + + +Record.register_type(NsRecord) diff --git a/octodns/record/ptr.py b/octodns/record/ptr.py new file mode 100644 index 0000000..6cecb06 --- /dev/null +++ b/octodns/record/ptr.py @@ -0,0 +1,24 @@ +# +# +# + +from .base import Record, ValuesMixin +from .target import _TargetsValue + + +class PtrValue(_TargetsValue): + pass + + +class PtrRecord(ValuesMixin, Record): + _type = 'PTR' + _value_type = PtrValue + + # This is for backward compatibility with providers that don't support + # multi-value PTR records. + @property + def value(self): + return self.values[0] + + +Record.register_type(PtrRecord) diff --git a/octodns/record/spf.py b/octodns/record/spf.py new file mode 100644 index 0000000..ac59c57 --- /dev/null +++ b/octodns/record/spf.py @@ -0,0 +1,14 @@ +# +# +# + +from .base import Record +from .chunked import _ChunkedValue, _ChunkedValuesMixin + + +class SpfRecord(_ChunkedValuesMixin, Record): + _type = 'SPF' + _value_type = _ChunkedValue + + +Record.register_type(SpfRecord) diff --git a/octodns/record/target.py b/octodns/record/target.py index 31e23cb..f9dbc18 100644 --- a/octodns/record/target.py +++ b/octodns/record/target.py @@ -5,8 +5,6 @@ from fqdn import FQDN from ..idna import idna_encode -from .base import Record, ValueMixin, ValuesMixin -from .dynamic import _DynamicMixin class _TargetValue(str): @@ -79,85 +77,3 @@ class _TargetsValue(str): @property def rdata_text(self): return self - - -class AliasValue(_TargetValue): - pass - - -class AliasRecord(ValueMixin, Record): - _type = 'ALIAS' - _value_type = AliasValue - - @classmethod - def validate(cls, name, fqdn, data): - reasons = [] - if name != '': - reasons.append('non-root ALIAS not allowed') - reasons.extend(super().validate(name, fqdn, data)) - return reasons - - -Record.register_type(AliasRecord) - - -class CnameValue(_TargetValue): - pass - - -class CnameRecord(_DynamicMixin, ValueMixin, Record): - _type = 'CNAME' - _value_type = CnameValue - - @classmethod - def validate(cls, name, fqdn, data): - reasons = [] - if name == '': - reasons.append('root CNAME not allowed') - reasons.extend(super().validate(name, fqdn, data)) - return reasons - - -Record.register_type(CnameRecord) - - -class DnameValue(_TargetValue): - pass - - -class DnameRecord(_DynamicMixin, ValueMixin, Record): - _type = 'DNAME' - _value_type = DnameValue - - -Record.register_type(DnameRecord) - - -class NsValue(_TargetsValue): - pass - - -class NsRecord(ValuesMixin, Record): - _type = 'NS' - _value_type = NsValue - - -Record.register_type(NsRecord) - - -class PtrValue(_TargetsValue): - pass - - -class PtrRecord(ValuesMixin, Record): - _type = 'PTR' - _value_type = PtrValue - - # This is for backward compatibility with providers that don't support - # multi-value PTR records. - @property - def value(self): - return self.values[0] - - -Record.register_type(PtrRecord) diff --git a/octodns/record/txt.py b/octodns/record/txt.py new file mode 100644 index 0000000..addc501 --- /dev/null +++ b/octodns/record/txt.py @@ -0,0 +1,18 @@ +# +# +# + +from .base import Record +from .chunked import _ChunkedValue, _ChunkedValuesMixin + + +class TxtValue(_ChunkedValue): + pass + + +class TxtRecord(_ChunkedValuesMixin, Record): + _type = 'TXT' + _value_type = TxtValue + + +Record.register_type(TxtRecord) diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index c5dcb1d..320152d 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -62,7 +62,7 @@ class TestRecord(TestCase): with self.assertRaises(RecordException) as ctx: Record.register_type(None, 'A') self.assertEqual( - 'Type "A" already registered by octodns.record.ipaddress.ARecord', + 'Type "A" already registered by octodns.record.a.ARecord', str(ctx.exception), ) From 90c0402b00f7f20f140695c6645886b34d4dd040 Mon Sep 17 00:00:00 2001 From: Ross McFarland Date: Mon, 2 Jan 2023 23:08:18 -0500 Subject: [PATCH 5/5] break record tests into individual files --- octodns/record/__init__.py | 2 +- octodns/record/{url.py => urlfwd.py} | 0 tests/test_octodns_record.py | 5871 -------------------------- tests/test_octodns_record_a.py | 181 + tests/test_octodns_record_aaaa.py | 227 + tests/test_octodns_record_alias.py | 108 + tests/test_octodns_record_caa.py | 273 ++ tests/test_octodns_record_change.py | 92 + tests/test_octodns_record_chunked.py | 37 + tests/test_octodns_record_cname.py | 136 + tests/test_octodns_record_dname.py | 94 + tests/test_octodns_record_ds.py | 206 + tests/test_octodns_record_dynamic.py | 1284 ++++++ tests/test_octodns_record_geo.py | 185 +- tests/test_octodns_record_ip.py | 30 + tests/test_octodns_record_loc.py | 697 +++ tests/test_octodns_record_mx.py | 265 ++ tests/test_octodns_record_naptr.py | 438 ++ tests/test_octodns_record_ns.py | 83 + tests/test_octodns_record_ptr.py | 89 + tests/test_octodns_record_spf.py | 71 + tests/test_octodns_record_srv.py | 432 ++ tests/test_octodns_record_sshfp.py | 330 ++ tests/test_octodns_record_target.py | 31 + tests/test_octodns_record_tlsa.py | 421 ++ tests/test_octodns_record_txt.py | 144 + tests/test_octodns_record_urlfwd.py | 391 ++ 27 files changed, 6245 insertions(+), 5873 deletions(-) rename octodns/record/{url.py => urlfwd.py} (100%) create mode 100644 tests/test_octodns_record_a.py create mode 100644 tests/test_octodns_record_aaaa.py create mode 100644 tests/test_octodns_record_alias.py create mode 100644 tests/test_octodns_record_caa.py create mode 100644 tests/test_octodns_record_change.py create mode 100644 tests/test_octodns_record_chunked.py create mode 100644 tests/test_octodns_record_cname.py create mode 100644 tests/test_octodns_record_dname.py create mode 100644 tests/test_octodns_record_ds.py create mode 100644 tests/test_octodns_record_dynamic.py create mode 100644 tests/test_octodns_record_ip.py create mode 100644 tests/test_octodns_record_loc.py create mode 100644 tests/test_octodns_record_mx.py create mode 100644 tests/test_octodns_record_naptr.py create mode 100644 tests/test_octodns_record_ns.py create mode 100644 tests/test_octodns_record_ptr.py create mode 100644 tests/test_octodns_record_spf.py create mode 100644 tests/test_octodns_record_srv.py create mode 100644 tests/test_octodns_record_sshfp.py create mode 100644 tests/test_octodns_record_target.py create mode 100644 tests/test_octodns_record_tlsa.py create mode 100644 tests/test_octodns_record_txt.py create mode 100644 tests/test_octodns_record_urlfwd.py diff --git a/octodns/record/__init__.py b/octodns/record/__init__.py index bd65b06..7ef1d6b 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -25,7 +25,7 @@ from .srv import SrvRecord, SrvValue from .sshfp import SshfpRecord, SshfpValue from .tlsa import TlsaRecord, TlsaValue from .txt import TxtValue, TxtRecord -from .url import UrlfwdRecord, UrlfwdValue +from .urlfwd import UrlfwdRecord, UrlfwdValue # quell warnings ARecord diff --git a/octodns/record/url.py b/octodns/record/urlfwd.py similarity index 100% rename from octodns/record/url.py rename to octodns/record/urlfwd.py diff --git a/tests/test_octodns_record.py b/tests/test_octodns_record.py index 320152d..5503fd8 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -7,53 +7,22 @@ from unittest import TestCase from octodns.idna import idna_encode from octodns.record import ( ARecord, - AaaaRecord, AliasRecord, - CaaRecord, - CaaValue, - CnameRecord, - DnameRecord, - DsValue, - DsRecord, Create, Delete, - GeoValue, - Ipv4Address, - LocRecord, - LocValue, - MxRecord, MxValue, - NaptrRecord, - NaptrValue, - NsRecord, NsValue, - PtrRecord, - PtrValue, Record, RecordException, Rr, - RrParseError, - SshfpRecord, - SshfpValue, - SpfRecord, - SrvRecord, SrvValue, - TlsaRecord, - TlsaValue, TxtRecord, Update, - UrlfwdRecord, - UrlfwdValue, ValidationError, ValuesMixin, ) -from octodns.record.chunked import _ChunkedValue -from octodns.record.dynamic import _Dynamic, _DynamicPool, _DynamicRule -from octodns.record.target import _TargetValue from octodns.zone import Zone -from helpers import DynamicProvider, GeoProvider, SimpleProvider - class TestRecord(TestCase): zone = Zone('unit.tests.', []) @@ -175,129 +144,6 @@ class TestRecord(TestCase): record.values[0], ) - def test_alias_lowering_value(self): - upper_record = AliasRecord( - self.zone, - 'aliasUppwerValue', - {'ttl': 30, 'type': 'ALIAS', 'value': 'GITHUB.COM'}, - ) - lower_record = AliasRecord( - self.zone, - 'aliasLowerValue', - {'ttl': 30, 'type': 'ALIAS', 'value': 'github.com'}, - ) - self.assertEqual(upper_record.value, lower_record.value) - - def test_cname_lowering_value(self): - upper_record = CnameRecord( - self.zone, - 'CnameUppwerValue', - {'ttl': 30, 'type': 'CNAME', 'value': 'GITHUB.COM'}, - ) - lower_record = CnameRecord( - self.zone, - 'CnameLowerValue', - {'ttl': 30, 'type': 'CNAME', 'value': 'github.com'}, - ) - self.assertEqual(upper_record.value, lower_record.value) - - def test_dname_lowering_value(self): - upper_record = DnameRecord( - self.zone, - 'DnameUppwerValue', - {'ttl': 30, 'type': 'DNAME', 'value': 'GITHUB.COM'}, - ) - lower_record = DnameRecord( - self.zone, - 'DnameLowerValue', - {'ttl': 30, 'type': 'DNAME', 'value': 'github.com'}, - ) - self.assertEqual(upper_record.value, lower_record.value) - - def test_ptr_lowering_value(self): - upper_record = PtrRecord( - self.zone, - 'PtrUppwerValue', - {'ttl': 30, 'type': 'PTR', 'value': 'GITHUB.COM.'}, - ) - lower_record = PtrRecord( - self.zone, - 'PtrLowerValue', - {'ttl': 30, 'type': 'PTR', 'value': 'github.com.'}, - ) - self.assertEqual(upper_record.value, lower_record.value) - - def test_a_and_record(self): - a_values = ['1.2.3.4', '2.2.3.4'] - a_data = {'ttl': 30, 'values': a_values} - a = ARecord(self.zone, 'a', a_data) - self.assertEqual('a', a.name) - self.assertEqual('a.unit.tests.', a.fqdn) - self.assertEqual(30, a.ttl) - self.assertEqual(a_values, a.values) - self.assertEqual(a_data, a.data) - - b_value = '3.2.3.4' - b_data = {'ttl': 30, 'value': b_value} - b = ARecord(self.zone, 'b', b_data) - self.assertEqual([b_value], b.values) - self.assertEqual(b_data, b.data) - - # top-level - data = {'ttl': 30, 'value': '4.2.3.4'} - self.assertEqual(self.zone.name, ARecord(self.zone, '', data).fqdn) - self.assertEqual(self.zone.name, ARecord(self.zone, None, data).fqdn) - - # ARecord equate with itself - self.assertTrue(a == a) - # Records with differing names and same type don't equate - self.assertFalse(a == b) - # Records with same name & type equate even if ttl is different - self.assertTrue( - a == ARecord(self.zone, 'a', {'ttl': 31, 'values': a_values}) - ) - # Records with same name & type equate even if values are different - self.assertTrue( - a == ARecord(self.zone, 'a', {'ttl': 30, 'value': b_value}) - ) - - target = SimpleProvider() - # no changes if self - self.assertFalse(a.changes(a, target)) - # no changes if clone - other = ARecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) - self.assertFalse(a.changes(other, target)) - # changes if ttl modified - other.ttl = 31 - update = a.changes(other, target) - self.assertEqual(a, update.existing) - self.assertEqual(other, update.new) - # changes if values modified - other.ttl = a.ttl - other.values = ['4.4.4.4'] - update = a.changes(other, target) - self.assertEqual(a, update.existing) - self.assertEqual(other, update.new) - - # Hashing - records = set() - records.add(a) - self.assertTrue(a in records) - self.assertFalse(b in records) - records.add(b) - self.assertTrue(b in records) - - # __repr__ doesn't blow up - a.__repr__() - # Record.__repr__ does - with self.assertRaises(NotImplementedError): - - class DummyRecord(Record): - def __init__(self): - pass - - DummyRecord().__repr__() - def test_from_rrs(self): # also tests ValuesMixin.data_from_rrs and ValueMixin.data_from_rrs rrs = ( @@ -330,26 +176,6 @@ class TestRecord(TestCase): # make sure there's nothing extra self.assertEqual(5, len(records)) - def test_ip_address_rdata_text(self): - - # anything goes, we're a noop - for s in ( - None, - '', - 'word', - 42, - 42.43, - '1.2.3', - 'some.words.that.here', - '1.2.word.4', - '1.2.3.4', - ): - self.assertEqual(s, Ipv4Address.parse_rdata_text(s)) - - zone = Zone('unit.tests.', []) - a = ARecord(zone, 'a', {'ttl': 42, 'value': '1.2.3.4'}) - self.assertEqual('1.2.3.4', a.values[0].rdata_text) - def test_values_mixin_data(self): # no values, no value or values in data a = ARecord(self.zone, '', {'type': 'A', 'ttl': 600, 'values': []}) @@ -387,1700 +213,6 @@ class TestRecord(TestCase): ) self.assertNotIn('value', a.data) - def test_geo(self): - geo_data = { - 'ttl': 42, - 'values': ['5.2.3.4', '6.2.3.4'], - 'geo': { - 'AF': ['1.1.1.1'], - 'AS-JP': ['2.2.2.2', '3.3.3.3'], - 'NA-US': ['4.4.4.4', '5.5.5.5'], - 'NA-US-CA': ['6.6.6.6', '7.7.7.7'], - }, - } - geo = ARecord(self.zone, 'geo', geo_data) - self.assertEqual(geo_data, geo.data) - - other_data = { - 'ttl': 42, - 'values': ['5.2.3.4', '6.2.3.4'], - 'geo': { - 'AF': ['1.1.1.1'], - 'AS-JP': ['2.2.2.2', '3.3.3.3'], - 'NA-US': ['4.4.4.4', '5.5.5.5'], - 'NA-US-CA': ['6.6.6.6', '7.7.7.7'], - }, - } - other = ARecord(self.zone, 'geo', other_data) - self.assertEqual(other_data, other.data) - - simple_target = SimpleProvider() - geo_target = GeoProvider() - - # Geo provider doesn't consider identical geo to be changes - self.assertFalse(geo.changes(geo, geo_target)) - - # geo values don't impact equality - other.geo['AF'].values = ['9.9.9.9'] - self.assertTrue(geo == other) - # Non-geo supporting provider doesn't consider geo diffs to be changes - self.assertFalse(geo.changes(other, simple_target)) - # Geo provider does consider geo diffs to be changes - self.assertTrue(geo.changes(other, geo_target)) - - # Object without geo doesn't impact equality - other.geo = {} - self.assertTrue(geo == other) - # Non-geo supporting provider doesn't consider lack of geo a diff - self.assertFalse(geo.changes(other, simple_target)) - # Geo provider does consider lack of geo diffs to be changes - self.assertTrue(geo.changes(other, geo_target)) - - # __repr__ doesn't blow up - geo.__repr__() - - def assertMultipleValues(self, _type, a_values, b_value): - a_data = {'ttl': 30, 'values': a_values} - a = _type(self.zone, 'a', a_data) - self.assertEqual('a', a.name) - self.assertEqual('a.unit.tests.', a.fqdn) - self.assertEqual(30, a.ttl) - self.assertEqual(a_values, a.values) - self.assertEqual(a_data, a.data) - - b_data = {'ttl': 30, 'value': b_value} - b = _type(self.zone, 'b', b_data) - self.assertEqual([b_value], b.values) - self.assertEqual(b_data, b.data) - - def test_aaaa(self): - a_values = [ - '2001:db8:3c4d:15::1a2f:1a2b', - '2001:db8:3c4d:15::1a2f:1a3b', - ] - b_value = '2001:db8:3c4d:15::1a2f:1a4b' - self.assertMultipleValues(AaaaRecord, a_values, b_value) - - # Specifically validate that we normalize IPv6 addresses - values = [ - '2001:db8:3c4d:15:0000:0000:1a2f:1a2b', - '2001:0db8:3c4d:0015::1a2f:1a3b', - ] - data = {'ttl': 30, 'values': values} - record = AaaaRecord(self.zone, 'aaaa', data) - self.assertEqual(a_values, record.values) - - def assertSingleValue(self, _type, a_value, b_value): - a_data = {'ttl': 30, 'value': a_value} - a = _type(self.zone, 'a', a_data) - self.assertEqual('a', a.name) - self.assertEqual('a.unit.tests.', a.fqdn) - self.assertEqual(30, a.ttl) - self.assertEqual(a_value, a.value) - self.assertEqual(a_data, a.data) - - b_data = {'ttl': 30, 'value': b_value} - b = _type(self.zone, 'b', b_data) - self.assertEqual(b_value, b.value) - self.assertEqual(b_data, b.data) - - target = SimpleProvider() - # No changes with self - self.assertFalse(a.changes(a, target)) - # Diff in value causes change - other = _type(self.zone, 'a', {'ttl': 30, 'value': b_value}) - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - - # __repr__ doesn't blow up - a.__repr__() - - def test_alias(self): - a_data = {'ttl': 0, 'value': 'www.unit.tests.'} - a = AliasRecord(self.zone, '', a_data) - self.assertEqual('', a.name) - self.assertEqual('unit.tests.', a.fqdn) - self.assertEqual(0, a.ttl) - self.assertEqual(a_data['value'], a.value) - self.assertEqual(a_data, a.data) - - target = SimpleProvider() - # No changes with self - self.assertFalse(a.changes(a, target)) - # Diff in value causes change - other = AliasRecord(self.zone, 'a', a_data) - other.value = 'foo.unit.tests.' - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - - # __repr__ doesn't blow up - a.__repr__() - - def test_target_rdata_text(self): - - # anything goes, we're a noop - for s in ( - None, - '', - 'word', - 42, - 42.43, - '1.2.3', - 'some.words.that.here', - '1.2.word.4', - '1.2.3.4', - ): - self.assertEqual(s, _TargetValue.parse_rdata_text(s)) - - zone = Zone('unit.tests.', []) - a = AliasRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) - self.assertEqual('some.target.', a.value.rdata_text) - - def test_caa(self): - a_values = [ - CaaValue({'flags': 0, 'tag': 'issue', 'value': 'ca.example.net'}), - CaaValue( - { - 'flags': 128, - 'tag': 'iodef', - 'value': 'mailto:security@example.com', - } - ), - ] - a_data = {'ttl': 30, 'values': a_values} - a = CaaRecord(self.zone, 'a', a_data) - self.assertEqual('a', a.name) - self.assertEqual('a.unit.tests.', a.fqdn) - self.assertEqual(30, a.ttl) - self.assertEqual(a_values[0]['flags'], a.values[0].flags) - self.assertEqual(a_values[0]['tag'], a.values[0].tag) - self.assertEqual(a_values[0]['value'], a.values[0].value) - self.assertEqual(a_values[1]['flags'], a.values[1].flags) - self.assertEqual(a_values[1]['tag'], a.values[1].tag) - self.assertEqual(a_values[1]['value'], a.values[1].value) - self.assertEqual(a_data, a.data) - - b_value = CaaValue( - {'tag': 'iodef', 'value': 'http://iodef.example.com/'} - ) - b_data = {'ttl': 30, 'value': b_value} - b = CaaRecord(self.zone, 'b', b_data) - self.assertEqual(0, b.values[0].flags) - self.assertEqual(b_value['tag'], b.values[0].tag) - self.assertEqual(b_value['value'], b.values[0].value) - b_data['value']['flags'] = 0 - self.assertEqual(b_data, b.data) - - target = SimpleProvider() - # No changes with self - self.assertFalse(a.changes(a, target)) - # Diff in flags causes change - other = CaaRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) - other.values[0].flags = 128 - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - # Diff in tag causes change - other.values[0].flags = a.values[0].flags - other.values[0].tag = 'foo' - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - # Diff in value causes change - other.values[0].tag = a.values[0].tag - other.values[0].value = 'bar' - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - - # __repr__ doesn't blow up - a.__repr__() - - def test_caa_value_rdata_text(self): - # empty string won't parse - with self.assertRaises(RrParseError): - CaaValue.parse_rdata_text('') - - # single word won't parse - with self.assertRaises(RrParseError): - CaaValue.parse_rdata_text('nope') - - # 2nd word won't parse - with self.assertRaises(RrParseError): - CaaValue.parse_rdata_text('0 tag') - - # 4th word won't parse - with self.assertRaises(RrParseError): - CaaValue.parse_rdata_text('1 tag value another') - - # flags not an int, will parse - self.assertEqual( - {'flags': 'one', 'tag': 'tag', 'value': 'value'}, - CaaValue.parse_rdata_text('one tag value'), - ) - - # valid - self.assertEqual( - {'flags': 0, 'tag': 'tag', 'value': '99148c81'}, - CaaValue.parse_rdata_text('0 tag 99148c81'), - ) - - zone = Zone('unit.tests.', []) - a = CaaRecord( - zone, - 'caa', - { - 'ttl': 32, - 'values': [ - {'flags': 1, 'tag': 'tag1', 'value': '99148c81'}, - {'flags': 2, 'tag': 'tag2', 'value': '99148c44'}, - ], - }, - ) - self.assertEqual(1, a.values[0].flags) - self.assertEqual('tag1', a.values[0].tag) - self.assertEqual('99148c81', a.values[0].value) - self.assertEqual('1 tag1 99148c81', a.values[0].rdata_text) - self.assertEqual(2, a.values[1].flags) - self.assertEqual('tag2', a.values[1].tag) - self.assertEqual('99148c44', a.values[1].value) - self.assertEqual('2 tag2 99148c44', a.values[1].rdata_text) - - def test_cname(self): - self.assertSingleValue(CnameRecord, 'target.foo.com.', 'other.foo.com.') - - def test_dname(self): - self.assertSingleValue(DnameRecord, 'target.foo.com.', 'other.foo.com.') - - def test_ds(self): - for a, b in ( - # diff flags - ( - { - 'flags': 0, - 'protocol': 1, - 'algorithm': 2, - 'public_key': 'abcdef0123456', - }, - { - 'flags': 1, - 'protocol': 1, - 'algorithm': 2, - 'public_key': 'abcdef0123456', - }, - ), - # diff protocol - ( - { - 'flags': 0, - 'protocol': 1, - 'algorithm': 2, - 'public_key': 'abcdef0123456', - }, - { - 'flags': 0, - 'protocol': 2, - 'algorithm': 2, - 'public_key': 'abcdef0123456', - }, - ), - # diff algorithm - ( - { - 'flags': 0, - 'protocol': 1, - 'algorithm': 2, - 'public_key': 'abcdef0123456', - }, - { - 'flags': 0, - 'protocol': 1, - 'algorithm': 3, - 'public_key': 'abcdef0123456', - }, - ), - # diff public_key - ( - { - 'flags': 0, - 'protocol': 1, - 'algorithm': 2, - 'public_key': 'abcdef0123456', - }, - { - 'flags': 0, - 'protocol': 1, - 'algorithm': 2, - 'public_key': 'bcdef0123456a', - }, - ), - ): - a = DsValue(a) - self.assertEqual(a, a) - b = DsValue(b) - self.assertEqual(b, b) - self.assertNotEqual(a, b) - self.assertNotEqual(b, a) - self.assertTrue(a < b) - - # empty string won't parse - with self.assertRaises(RrParseError): - DsValue.parse_rdata_text('') - - # single word won't parse - with self.assertRaises(RrParseError): - DsValue.parse_rdata_text('nope') - - # 2nd word won't parse - with self.assertRaises(RrParseError): - DsValue.parse_rdata_text('0 1') - - # 3rd word won't parse - with self.assertRaises(RrParseError): - DsValue.parse_rdata_text('0 1 2') - - # 5th word won't parse - with self.assertRaises(RrParseError): - DsValue.parse_rdata_text('0 1 2 key blah') - - # things ints, will parse - self.assertEqual( - { - 'flags': 'one', - 'protocol': 'two', - 'algorithm': 'three', - 'public_key': 'key', - }, - DsValue.parse_rdata_text('one two three key'), - ) - - # valid - data = { - 'flags': 0, - 'protocol': 1, - 'algorithm': 2, - 'public_key': '99148c81', - } - self.assertEqual(data, DsValue.parse_rdata_text('0 1 2 99148c81')) - self.assertEqual([], DsValue.validate(data, 'DS')) - - # missing flags - data = {'protocol': 1, 'algorithm': 2, 'public_key': '99148c81'} - self.assertEqual(['missing flags'], DsValue.validate(data, 'DS')) - # invalid flags - data = { - 'flags': 'a', - 'protocol': 1, - 'algorithm': 2, - 'public_key': '99148c81', - } - self.assertEqual(['invalid flags "a"'], DsValue.validate(data, 'DS')) - - # missing protocol - data = {'flags': 1, 'algorithm': 2, 'public_key': '99148c81'} - self.assertEqual(['missing protocol'], DsValue.validate(data, 'DS')) - # invalid protocol - data = { - 'flags': 1, - 'protocol': 'a', - 'algorithm': 2, - 'public_key': '99148c81', - } - self.assertEqual(['invalid protocol "a"'], DsValue.validate(data, 'DS')) - - # missing algorithm - data = {'flags': 1, 'protocol': 2, 'public_key': '99148c81'} - self.assertEqual(['missing algorithm'], DsValue.validate(data, 'DS')) - # invalid algorithm - data = { - 'flags': 1, - 'protocol': 2, - 'algorithm': 'a', - 'public_key': '99148c81', - } - self.assertEqual( - ['invalid algorithm "a"'], DsValue.validate(data, 'DS') - ) - - # missing algorithm (list) - data = {'flags': 1, 'protocol': 2, 'algorithm': 3} - self.assertEqual(['missing public_key'], DsValue.validate([data], 'DS')) - - zone = Zone('unit.tests.', []) - values = [ - { - 'flags': 0, - 'protocol': 1, - 'algorithm': 2, - 'public_key': '99148c81', - }, - { - 'flags': 1, - 'protocol': 2, - 'algorithm': 3, - 'public_key': '99148c44', - }, - ] - a = DsRecord(zone, 'ds', {'ttl': 32, 'values': values}) - self.assertEqual(0, a.values[0].flags) - a.values[0].flags += 1 - self.assertEqual(1, a.values[0].flags) - - self.assertEqual(1, a.values[0].protocol) - a.values[0].protocol += 1 - self.assertEqual(2, a.values[0].protocol) - - self.assertEqual(2, a.values[0].algorithm) - a.values[0].algorithm += 1 - self.assertEqual(3, a.values[0].algorithm) - - self.assertEqual('99148c81', a.values[0].public_key) - a.values[0].public_key = '99148c42' - self.assertEqual('99148c42', a.values[0].public_key) - - self.assertEqual(1, a.values[1].flags) - self.assertEqual(2, a.values[1].protocol) - self.assertEqual(3, a.values[1].algorithm) - self.assertEqual('99148c44', a.values[1].public_key) - - self.assertEqual(DsValue(values[1]), a.values[1].data) - self.assertEqual('1 2 3 99148c44', a.values[1].rdata_text) - self.assertEqual('1 2 3 99148c44', a.values[1].__repr__()) - - def test_loc(self): - a_values = [ - LocValue( - { - 'lat_degrees': 31, - 'lat_minutes': 58, - 'lat_seconds': 52.1, - 'lat_direction': 'S', - 'long_degrees': 115, - 'long_minutes': 49, - 'long_seconds': 11.7, - 'long_direction': 'E', - 'altitude': 20, - 'size': 10, - 'precision_horz': 10, - 'precision_vert': 2, - } - ) - ] - a_data = {'ttl': 30, 'values': a_values} - a = LocRecord(self.zone, 'a', a_data) - self.assertEqual('a', a.name) - self.assertEqual('a.unit.tests.', a.fqdn) - self.assertEqual(30, a.ttl) - self.assertEqual(a_values[0]['lat_degrees'], a.values[0].lat_degrees) - self.assertEqual(a_values[0]['lat_minutes'], a.values[0].lat_minutes) - self.assertEqual(a_values[0]['lat_seconds'], a.values[0].lat_seconds) - self.assertEqual( - a_values[0]['lat_direction'], a.values[0].lat_direction - ) - self.assertEqual(a_values[0]['long_degrees'], a.values[0].long_degrees) - self.assertEqual(a_values[0]['long_minutes'], a.values[0].long_minutes) - self.assertEqual(a_values[0]['long_seconds'], a.values[0].long_seconds) - self.assertEqual( - a_values[0]['long_direction'], a.values[0].long_direction - ) - self.assertEqual(a_values[0]['altitude'], a.values[0].altitude) - self.assertEqual(a_values[0]['size'], a.values[0].size) - self.assertEqual( - a_values[0]['precision_horz'], a.values[0].precision_horz - ) - self.assertEqual( - a_values[0]['precision_vert'], a.values[0].precision_vert - ) - - b_value = LocValue( - { - 'lat_degrees': 32, - 'lat_minutes': 7, - 'lat_seconds': 19, - 'lat_direction': 'S', - 'long_degrees': 116, - 'long_minutes': 2, - 'long_seconds': 25, - 'long_direction': 'E', - 'altitude': 10, - 'size': 1, - 'precision_horz': 10000, - 'precision_vert': 10, - } - ) - b_data = {'ttl': 30, 'value': b_value} - b = LocRecord(self.zone, 'b', b_data) - self.assertEqual(b_value['lat_degrees'], b.values[0].lat_degrees) - self.assertEqual(b_value['lat_minutes'], b.values[0].lat_minutes) - self.assertEqual(b_value['lat_seconds'], b.values[0].lat_seconds) - self.assertEqual(b_value['lat_direction'], b.values[0].lat_direction) - self.assertEqual(b_value['long_degrees'], b.values[0].long_degrees) - self.assertEqual(b_value['long_minutes'], b.values[0].long_minutes) - self.assertEqual(b_value['long_seconds'], b.values[0].long_seconds) - self.assertEqual(b_value['long_direction'], b.values[0].long_direction) - self.assertEqual(b_value['altitude'], b.values[0].altitude) - self.assertEqual(b_value['size'], b.values[0].size) - self.assertEqual(b_value['precision_horz'], b.values[0].precision_horz) - self.assertEqual(b_value['precision_vert'], b.values[0].precision_vert) - self.assertEqual(b_data, b.data) - - target = SimpleProvider() - # No changes with self - self.assertFalse(a.changes(a, target)) - # Diff in lat_direction causes change - other = LocRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) - other.values[0].lat_direction = 'N' - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - # Diff in altitude causes change - other.values[0].altitude = a.values[0].altitude - other.values[0].altitude = -10 - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - - # __repr__ doesn't blow up - a.__repr__() - - def test_loc_value_rdata_text(self): - # only the exact correct number of words is allowed - for i in tuple(range(0, 12)) + (13,): - s = ''.join(['word'] * i) - with self.assertRaises(RrParseError): - LocValue.parse_rdata_text(s) - - # type conversions are best effort - self.assertEqual( - { - 'altitude': 'six', - 'lat_degrees': 'zero', - 'lat_direction': 'S', - 'lat_minutes': 'one', - 'lat_seconds': 'two', - 'long_degrees': 'three', - 'long_direction': 'W', - 'long_minutes': 'four', - 'long_seconds': 'five', - 'precision_horz': 'eight', - 'precision_vert': 'nine', - 'size': 'seven', - }, - LocValue.parse_rdata_text( - 'zero one two S three four five W six seven eight nine' - ), - ) - - # valid - s = '0 1 2.2 N 3 4 5.5 E 6.6m 7.7m 8.8m 9.9m' - self.assertEqual( - { - 'altitude': 6.6, - 'lat_degrees': 0, - 'lat_direction': 'N', - 'lat_minutes': 1, - 'lat_seconds': 2.2, - 'long_degrees': 3, - 'long_direction': 'E', - 'long_minutes': 4, - 'long_seconds': 5.5, - 'precision_horz': 8.8, - 'precision_vert': 9.9, - 'size': 7.7, - }, - LocValue.parse_rdata_text(s), - ) - - # make sure that the cstor is using parse_rdata_text - zone = Zone('unit.tests.', []) - a = LocRecord( - zone, - 'mx', - { - 'type': 'LOC', - 'ttl': 42, - 'value': { - 'altitude': 6.6, - 'lat_degrees': 0, - 'lat_direction': 'N', - 'lat_minutes': 1, - 'lat_seconds': 2.2, - 'long_degrees': 3, - 'long_direction': 'E', - 'long_minutes': 4, - 'long_seconds': 5.5, - 'precision_horz': 8.8, - 'precision_vert': 9.9, - 'size': 7.7, - }, - }, - ) - self.assertEqual(0, a.values[0].lat_degrees) - self.assertEqual(1, a.values[0].lat_minutes) - self.assertEqual(2.2, a.values[0].lat_seconds) - self.assertEqual('N', a.values[0].lat_direction) - self.assertEqual(3, a.values[0].long_degrees) - self.assertEqual(4, a.values[0].long_minutes) - self.assertEqual(5.5, a.values[0].long_seconds) - self.assertEqual('E', a.values[0].long_direction) - self.assertEqual(6.6, a.values[0].altitude) - self.assertEqual(7.7, a.values[0].size) - self.assertEqual(8.8, a.values[0].precision_horz) - self.assertEqual(9.9, a.values[0].precision_vert) - self.assertEqual(s, a.values[0].rdata_text) - - def test_mx(self): - a_values = [ - MxValue({'preference': 10, 'exchange': 'smtp1.'}), - MxValue({'priority': 20, 'value': 'smtp2.'}), - ] - a_data = {'ttl': 30, 'values': a_values} - a = MxRecord(self.zone, 'a', a_data) - self.assertEqual('a', a.name) - self.assertEqual('a.unit.tests.', a.fqdn) - self.assertEqual(30, a.ttl) - self.assertEqual(a_values[0]['preference'], a.values[0].preference) - self.assertEqual(a_values[0]['exchange'], a.values[0].exchange) - self.assertEqual(a_values[1]['preference'], a.values[1].preference) - self.assertEqual(a_values[1]['exchange'], a.values[1].exchange) - a_data['values'][1] = MxValue({'preference': 20, 'exchange': 'smtp2.'}) - self.assertEqual(a_data, a.data) - - b_value = MxValue({'preference': 0, 'exchange': 'smtp3.'}) - b_data = {'ttl': 30, 'value': b_value} - b = MxRecord(self.zone, 'b', b_data) - self.assertEqual(b_value['preference'], b.values[0].preference) - self.assertEqual(b_value['exchange'], b.values[0].exchange) - self.assertEqual(b_data, b.data) - - a_upper_values = [ - {'preference': 10, 'exchange': 'SMTP1.'}, - {'priority': 20, 'value': 'SMTP2.'}, - ] - a_upper_data = {'ttl': 30, 'values': a_upper_values} - a_upper = MxRecord(self.zone, 'a', a_upper_data) - self.assertEqual(a_upper.data, a.data) - - target = SimpleProvider() - # No changes with self - self.assertFalse(a.changes(a, target)) - # Diff in preference causes change - other = MxRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) - other.values[0].preference = 22 - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - # Diff in value causes change - other.values[0].preference = a.values[0].preference - other.values[0].exchange = 'smtpX' - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - - # __repr__ doesn't blow up - a.__repr__() - - def test_mx_value_rdata_text(self): - - # empty string won't parse - with self.assertRaises(RrParseError): - MxValue.parse_rdata_text('') - - # single word won't parse - with self.assertRaises(RrParseError): - MxValue.parse_rdata_text('nope') - - # 3rd word won't parse - with self.assertRaises(RrParseError): - MxValue.parse_rdata_text('10 mx.unit.tests. another') - - # preference not an int - self.assertEqual( - {'preference': 'abc', 'exchange': 'mx.unit.tests.'}, - MxValue.parse_rdata_text('abc mx.unit.tests.'), - ) - - # valid - self.assertEqual( - {'preference': 10, 'exchange': 'mx.unit.tests.'}, - MxValue.parse_rdata_text('10 mx.unit.tests.'), - ) - - zone = Zone('unit.tests.', []) - a = MxRecord( - zone, - 'mx', - { - 'ttl': 32, - 'values': [ - {'preference': 11, 'exchange': 'mail1.unit.tests.'}, - {'preference': 12, 'exchange': 'mail2.unit.tests.'}, - ], - }, - ) - self.assertEqual(11, a.values[0].preference) - self.assertEqual('mail1.unit.tests.', a.values[0].exchange) - self.assertEqual('11 mail1.unit.tests.', a.values[0].rdata_text) - self.assertEqual(12, a.values[1].preference) - self.assertEqual('mail2.unit.tests.', a.values[1].exchange) - self.assertEqual('12 mail2.unit.tests.', a.values[1].rdata_text) - - def test_naptr(self): - a_values = [ - NaptrValue( - { - 'order': 10, - 'preference': 11, - 'flags': 'X', - 'service': 'Y', - 'regexp': 'Z', - 'replacement': '.', - } - ), - NaptrValue( - { - 'order': 20, - 'preference': 21, - 'flags': 'A', - 'service': 'B', - 'regexp': 'C', - 'replacement': 'foo.com', - } - ), - ] - a_data = {'ttl': 30, 'values': a_values} - a = NaptrRecord(self.zone, 'a', a_data) - self.assertEqual('a', a.name) - self.assertEqual('a.unit.tests.', a.fqdn) - self.assertEqual(30, a.ttl) - for i in (0, 1): - for k in a_values[0].keys(): - self.assertEqual(a_values[i][k], getattr(a.values[i], k)) - self.assertEqual(a_data, a.data) - - b_value = NaptrValue( - { - 'order': 30, - 'preference': 31, - 'flags': 'M', - 'service': 'N', - 'regexp': 'O', - 'replacement': 'x', - } - ) - b_data = {'ttl': 30, 'value': b_value} - b = NaptrRecord(self.zone, 'b', b_data) - for k in a_values[0].keys(): - self.assertEqual(b_value[k], getattr(b.values[0], k)) - self.assertEqual(b_data, b.data) - - target = SimpleProvider() - # No changes with self - self.assertFalse(a.changes(a, target)) - # Diff in priority causes change - other = NaptrRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) - other.values[0].order = 22 - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - # Diff in replacement causes change - other.values[0].order = a.values[0].order - other.values[0].replacement = 'smtpX' - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - - # full sorting - # equivalent - b_naptr_value = b.values[0] - self.assertTrue(b_naptr_value == b_naptr_value) - self.assertFalse(b_naptr_value != b_naptr_value) - self.assertTrue(b_naptr_value <= b_naptr_value) - self.assertTrue(b_naptr_value >= b_naptr_value) - # by order - self.assertTrue( - b_naptr_value - > NaptrValue( - { - 'order': 10, - 'preference': 31, - 'flags': 'M', - 'service': 'N', - 'regexp': 'O', - 'replacement': 'x', - } - ) - ) - self.assertTrue( - b_naptr_value - < NaptrValue( - { - 'order': 40, - 'preference': 31, - 'flags': 'M', - 'service': 'N', - 'regexp': 'O', - 'replacement': 'x', - } - ) - ) - # by preference - self.assertTrue( - b_naptr_value - > NaptrValue( - { - 'order': 30, - 'preference': 10, - 'flags': 'M', - 'service': 'N', - 'regexp': 'O', - 'replacement': 'x', - } - ) - ) - self.assertTrue( - b_naptr_value - < NaptrValue( - { - 'order': 30, - 'preference': 40, - 'flags': 'M', - 'service': 'N', - 'regexp': 'O', - 'replacement': 'x', - } - ) - ) - # by flags - self.assertTrue( - b_naptr_value - > NaptrValue( - { - 'order': 30, - 'preference': 31, - 'flags': 'A', - 'service': 'N', - 'regexp': 'O', - 'replacement': 'x', - } - ) - ) - self.assertTrue( - b_naptr_value - < NaptrValue( - { - 'order': 30, - 'preference': 31, - 'flags': 'Z', - 'service': 'N', - 'regexp': 'O', - 'replacement': 'x', - } - ) - ) - # by service - self.assertTrue( - b_naptr_value - > NaptrValue( - { - 'order': 30, - 'preference': 31, - 'flags': 'M', - 'service': 'A', - 'regexp': 'O', - 'replacement': 'x', - } - ) - ) - self.assertTrue( - b_naptr_value - < NaptrValue( - { - 'order': 30, - 'preference': 31, - 'flags': 'M', - 'service': 'Z', - 'regexp': 'O', - 'replacement': 'x', - } - ) - ) - # by regexp - self.assertTrue( - b_naptr_value - > NaptrValue( - { - 'order': 30, - 'preference': 31, - 'flags': 'M', - 'service': 'N', - 'regexp': 'A', - 'replacement': 'x', - } - ) - ) - self.assertTrue( - b_naptr_value - < NaptrValue( - { - 'order': 30, - 'preference': 31, - 'flags': 'M', - 'service': 'N', - 'regexp': 'Z', - 'replacement': 'x', - } - ) - ) - # by replacement - self.assertTrue( - b_naptr_value - > NaptrValue( - { - 'order': 30, - 'preference': 31, - 'flags': 'M', - 'service': 'N', - 'regexp': 'O', - 'replacement': 'a', - } - ) - ) - self.assertTrue( - b_naptr_value - < NaptrValue( - { - 'order': 30, - 'preference': 31, - 'flags': 'M', - 'service': 'N', - 'regexp': 'O', - 'replacement': 'z', - } - ) - ) - - # __repr__ doesn't blow up - a.__repr__() - - # Hash - v = NaptrValue( - { - 'order': 30, - 'preference': 31, - 'flags': 'M', - 'service': 'N', - 'regexp': 'O', - 'replacement': 'z', - } - ) - o = NaptrValue( - { - 'order': 30, - 'preference': 32, - 'flags': 'M', - 'service': 'N', - 'regexp': 'O', - 'replacement': 'z', - } - ) - values = set() - values.add(v) - self.assertTrue(v in values) - self.assertFalse(o in values) - values.add(o) - self.assertTrue(o in values) - - self.assertEqual(30, o.order) - o.order = o.order + 1 - self.assertEqual(31, o.order) - - self.assertEqual(32, o.preference) - o.preference = o.preference + 1 - self.assertEqual(33, o.preference) - - self.assertEqual('M', o.flags) - o.flags = 'P' - self.assertEqual('P', o.flags) - - self.assertEqual('N', o.service) - o.service = 'Q' - self.assertEqual('Q', o.service) - - self.assertEqual('O', o.regexp) - o.regexp = 'R' - self.assertEqual('R', o.regexp) - - self.assertEqual('z', o.replacement) - o.replacement = '1' - self.assertEqual('1', o.replacement) - - def test_naptr_value_rdata_text(self): - # things with the wrong number of words won't parse - for v in ( - '', - 'one', - 'one two', - 'one two three', - 'one two three four', - 'one two three four five', - 'one two three four five six seven', - ): - with self.assertRaises(RrParseError): - NaptrValue.parse_rdata_text(v) - - # we don't care if the types of things are correct when parsing rr text - self.assertEqual( - { - 'order': 'one', - 'preference': 'two', - 'flags': 'three', - 'service': 'four', - 'regexp': 'five', - 'replacement': 'six', - }, - NaptrValue.parse_rdata_text('one two three four five six'), - ) - - # order and preference will be converted to int's when possible - self.assertEqual( - { - 'order': 1, - 'preference': 2, - 'flags': 'three', - 'service': 'four', - 'regexp': 'five', - 'replacement': 'six', - }, - NaptrValue.parse_rdata_text('1 2 three four five six'), - ) - - # make sure that the cstor is using parse_rdata_text - zone = Zone('unit.tests.', []) - a = NaptrRecord( - zone, - 'naptr', - { - 'ttl': 32, - 'value': { - 'order': 1, - 'preference': 2, - 'flags': 'S', - 'service': 'service', - 'regexp': 'regexp', - 'replacement': 'replacement', - }, - }, - ) - self.assertEqual(1, a.values[0].order) - self.assertEqual(2, a.values[0].preference) - self.assertEqual('S', a.values[0].flags) - self.assertEqual('service', a.values[0].service) - self.assertEqual('regexp', a.values[0].regexp) - self.assertEqual('replacement', a.values[0].replacement) - s = '1 2 S service regexp replacement' - self.assertEqual(s, a.values[0].rdata_text) - - def test_ns(self): - a_values = ['5.6.7.8.', '6.7.8.9.', '7.8.9.0.'] - a_data = {'ttl': 30, 'values': a_values} - a = NsRecord(self.zone, 'a', a_data) - self.assertEqual('a', a.name) - self.assertEqual('a.unit.tests.', a.fqdn) - self.assertEqual(30, a.ttl) - self.assertEqual(a_values, a.values) - self.assertEqual(a_data, a.data) - - b_value = '9.8.7.6.' - b_data = {'ttl': 30, 'value': b_value} - b = NsRecord(self.zone, 'b', b_data) - self.assertEqual([b_value], b.values) - self.assertEqual(b_data, b.data) - - def test_ns_value_rdata_text(self): - # anything goes, we're a noop - for s in ( - None, - '', - 'word', - 42, - 42.43, - '1.2.3', - 'some.words.that.here', - '1.2.word.4', - '1.2.3.4', - ): - self.assertEqual(s, NsValue.parse_rdata_text(s)) - - zone = Zone('unit.tests.', []) - a = NsRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) - self.assertEqual('some.target.', a.values[0].rdata_text) - - def test_sshfp(self): - a_values = [ - SshfpValue( - { - 'algorithm': 10, - 'fingerprint_type': 11, - 'fingerprint': 'abc123', - } - ), - SshfpValue( - { - 'algorithm': 20, - 'fingerprint_type': 21, - 'fingerprint': 'def456', - } - ), - ] - a_data = {'ttl': 30, 'values': a_values} - a = SshfpRecord(self.zone, 'a', a_data) - self.assertEqual('a', a.name) - self.assertEqual('a.unit.tests.', a.fqdn) - self.assertEqual(30, a.ttl) - self.assertEqual(a_values[0]['algorithm'], a.values[0].algorithm) - self.assertEqual( - a_values[0]['fingerprint_type'], a.values[0].fingerprint_type - ) - self.assertEqual(a_values[0]['fingerprint'], a.values[0].fingerprint) - self.assertEqual(a_data, a.data) - - b_value = SshfpValue( - {'algorithm': 30, 'fingerprint_type': 31, 'fingerprint': 'ghi789'} - ) - b_data = {'ttl': 30, 'value': b_value} - b = SshfpRecord(self.zone, 'b', b_data) - self.assertEqual(b_value['algorithm'], b.values[0].algorithm) - self.assertEqual( - b_value['fingerprint_type'], b.values[0].fingerprint_type - ) - self.assertEqual(b_value['fingerprint'], b.values[0].fingerprint) - self.assertEqual(b_data, b.data) - - target = SimpleProvider() - # No changes with self - self.assertFalse(a.changes(a, target)) - # Diff in algorithm causes change - other = SshfpRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) - other.values[0].algorithm = 22 - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - # Diff in fingerprint_type causes change - other = SshfpRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) - other.values[0].algorithm = a.values[0].algorithm - other.values[0].fingerprint_type = 22 - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - # Diff in fingerprint causes change - other = SshfpRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) - other.values[0].fingerprint_type = a.values[0].fingerprint_type - other.values[0].fingerprint = 22 - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - - # __repr__ doesn't blow up - a.__repr__() - - def test_sshfp_value_rdata_text(self): - - # empty string won't parse - with self.assertRaises(RrParseError): - SshfpValue.parse_rdata_text('') - - # single word won't parse - with self.assertRaises(RrParseError): - SshfpValue.parse_rdata_text('nope') - - # 3rd word won't parse - with self.assertRaises(RrParseError): - SshfpValue.parse_rdata_text('0 1 00479b27 another') - - # algorithm and fingerprint_type not ints - self.assertEqual( - { - 'algorithm': 'one', - 'fingerprint_type': 'two', - 'fingerprint': '00479b27', - }, - SshfpValue.parse_rdata_text('one two 00479b27'), - ) - - # valid - self.assertEqual( - {'algorithm': 1, 'fingerprint_type': 2, 'fingerprint': '00479b27'}, - SshfpValue.parse_rdata_text('1 2 00479b27'), - ) - - zone = Zone('unit.tests.', []) - a = SshfpRecord( - zone, - 'sshfp', - { - 'ttl': 32, - 'value': { - 'algorithm': 1, - 'fingerprint_type': 2, - 'fingerprint': '00479b27', - }, - }, - ) - self.assertEqual(1, a.values[0].algorithm) - self.assertEqual(2, a.values[0].fingerprint_type) - self.assertEqual('00479b27', a.values[0].fingerprint) - self.assertEqual('1 2 00479b27', a.values[0].rdata_text) - - def test_spf(self): - a_values = ['spf1 -all', 'spf1 -hrm'] - b_value = 'spf1 -other' - self.assertMultipleValues(SpfRecord, a_values, b_value) - - def test_chunked_value_rdata_text(self): - for s in ( - None, - '', - 'word', - 42, - 42.43, - '1.2.3', - 'some.words.that.here', - '1.2.word.4', - '1.2.3.4', - ): - self.assertEqual(s, _ChunkedValue.parse_rdata_text(s)) - - # semi-colons are escaped - self.assertEqual( - 'Hello\\; World!', _ChunkedValue.parse_rdata_text('Hello; World!') - ) - - # since we're always a string validate and __init__ don't - # parse_rdata_text - - zone = Zone('unit.tests.', []) - a = SpfRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) - self.assertEqual('some.target.', a.values[0].rdata_text) - - def test_srv(self): - a_values = [ - SrvValue( - {'priority': 10, 'weight': 11, 'port': 12, 'target': 'server1'} - ), - SrvValue( - {'priority': 20, 'weight': 21, 'port': 22, 'target': 'server2'} - ), - ] - a_data = {'ttl': 30, 'values': a_values} - a = SrvRecord(self.zone, '_a._tcp', a_data) - self.assertEqual('_a._tcp', a.name) - self.assertEqual('_a._tcp.unit.tests.', a.fqdn) - self.assertEqual(30, a.ttl) - self.assertEqual(a_values[0]['priority'], a.values[0].priority) - self.assertEqual(a_values[0]['weight'], a.values[0].weight) - self.assertEqual(a_values[0]['port'], a.values[0].port) - self.assertEqual(a_values[0]['target'], a.values[0].target) - self.assertEqual(a_data, a.data) - - b_value = SrvValue( - {'priority': 30, 'weight': 31, 'port': 32, 'target': 'server3'} - ) - b_data = {'ttl': 30, 'value': b_value} - b = SrvRecord(self.zone, '_b._tcp', b_data) - self.assertEqual(b_value['priority'], b.values[0].priority) - self.assertEqual(b_value['weight'], b.values[0].weight) - self.assertEqual(b_value['port'], b.values[0].port) - self.assertEqual(b_value['target'], b.values[0].target) - self.assertEqual(b_data, b.data) - - target = SimpleProvider() - # No changes with self - self.assertFalse(a.changes(a, target)) - # Diff in priority causes change - other = SrvRecord( - self.zone, '_a._icmp', {'ttl': 30, 'values': a_values} - ) - other.values[0].priority = 22 - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - # Diff in weight causes change - other.values[0].priority = a.values[0].priority - other.values[0].weight = 33 - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - # Diff in port causes change - other.values[0].weight = a.values[0].weight - other.values[0].port = 44 - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - # Diff in target causes change - other.values[0].port = a.values[0].port - other.values[0].target = 'serverX' - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - - # __repr__ doesn't blow up - a.__repr__() - - def test_srv_value_rdata_text(self): - - # empty string won't parse - with self.assertRaises(RrParseError): - SrvValue.parse_rdata_text('') - - # single word won't parse - with self.assertRaises(RrParseError): - SrvValue.parse_rdata_text('nope') - - # 2nd word won't parse - with self.assertRaises(RrParseError): - SrvValue.parse_rdata_text('1 2') - - # 3rd word won't parse - with self.assertRaises(RrParseError): - SrvValue.parse_rdata_text('1 2 3') - - # 5th word won't parse - with self.assertRaises(RrParseError): - SrvValue.parse_rdata_text('1 2 3 4 5') - - # priority weight and port not ints - self.assertEqual( - { - 'priority': 'one', - 'weight': 'two', - 'port': 'three', - 'target': 'srv.unit.tests.', - }, - SrvValue.parse_rdata_text('one two three srv.unit.tests.'), - ) - - # valid - self.assertEqual( - { - 'priority': 1, - 'weight': 2, - 'port': 3, - 'target': 'srv.unit.tests.', - }, - SrvValue.parse_rdata_text('1 2 3 srv.unit.tests.'), - ) - - zone = Zone('unit.tests.', []) - a = SrvRecord( - zone, - '_srv._tcp', - { - 'ttl': 32, - 'value': { - 'priority': 1, - 'weight': 2, - 'port': 3, - 'target': 'srv.unit.tests.', - }, - }, - ) - self.assertEqual(1, a.values[0].priority) - self.assertEqual(2, a.values[0].weight) - self.assertEqual(3, a.values[0].port) - self.assertEqual('srv.unit.tests.', a.values[0].target) - - def test_tlsa(self): - a_values = [ - TlsaValue( - { - 'certificate_usage': 1, - 'selector': 1, - 'matching_type': 1, - 'certificate_association_data': 'ABABABABABABABABAB', - } - ), - TlsaValue( - { - 'certificate_usage': 2, - 'selector': 0, - 'matching_type': 2, - 'certificate_association_data': 'ABABABABABABABABAC', - } - ), - ] - a_data = {'ttl': 30, 'values': a_values} - a = TlsaRecord(self.zone, 'a', a_data) - self.assertEqual('a.unit.tests.', a.fqdn) - self.assertEqual('a', a.name) - self.assertEqual(30, a.ttl) - self.assertEqual( - a_values[0]['certificate_usage'], a.values[0].certificate_usage - ) - self.assertEqual(a_values[0]['selector'], a.values[0].selector) - self.assertEqual( - a_values[0]['matching_type'], a.values[0].matching_type - ) - self.assertEqual( - a_values[0]['certificate_association_data'], - a.values[0].certificate_association_data, - ) - - self.assertEqual( - a_values[1]['certificate_usage'], a.values[1].certificate_usage - ) - self.assertEqual(a_values[1]['selector'], a.values[1].selector) - self.assertEqual( - a_values[1]['matching_type'], a.values[1].matching_type - ) - self.assertEqual( - a_values[1]['certificate_association_data'], - a.values[1].certificate_association_data, - ) - self.assertEqual(a_data, a.data) - - b_value = TlsaValue( - { - 'certificate_usage': 0, - 'selector': 0, - 'matching_type': 0, - 'certificate_association_data': 'AAAAAAAAAAAAAAA', - } - ) - b_data = {'ttl': 30, 'value': b_value} - b = TlsaRecord(self.zone, 'b', b_data) - self.assertEqual( - b_value['certificate_usage'], b.values[0].certificate_usage - ) - self.assertEqual(b_value['selector'], b.values[0].selector) - self.assertEqual(b_value['matching_type'], b.values[0].matching_type) - self.assertEqual( - b_value['certificate_association_data'], - b.values[0].certificate_association_data, - ) - self.assertEqual(b_data, b.data) - - target = SimpleProvider() - # No changes with self - self.assertFalse(a.changes(a, target)) - # Diff in certificate_usage causes change - other = TlsaRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) - other.values[0].certificate_usage = 0 - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - # Diff in selector causes change - other = TlsaRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) - other.values[0].selector = 0 - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - # Diff in matching_type causes change - other = TlsaRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) - other.values[0].matching_type = 0 - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - # Diff in certificate_association_data causes change - other = TlsaRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) - other.values[0].certificate_association_data = 'AAAAAAAAAAAAA' - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - - # __repr__ doesn't blow up - a.__repr__() - - def test_tsla_value_rdata_text(self): - - # empty string won't parse - with self.assertRaises(RrParseError): - TlsaValue.parse_rdata_text('') - - # single word won't parse - with self.assertRaises(RrParseError): - TlsaValue.parse_rdata_text('nope') - - # 2nd word won't parse - with self.assertRaises(RrParseError): - TlsaValue.parse_rdata_text('1 2') - - # 3rd word won't parse - with self.assertRaises(RrParseError): - TlsaValue.parse_rdata_text('1 2 3') - - # 5th word won't parse - with self.assertRaises(RrParseError): - TlsaValue.parse_rdata_text('1 2 3 abcd another') - - # non-ints - self.assertEqual( - { - 'certificate_usage': 'one', - 'selector': 'two', - 'matching_type': 'three', - 'certificate_association_data': 'abcd', - }, - TlsaValue.parse_rdata_text('one two three abcd'), - ) - - # valid - self.assertEqual( - { - 'certificate_usage': 1, - 'selector': 2, - 'matching_type': 3, - 'certificate_association_data': 'abcd', - }, - TlsaValue.parse_rdata_text('1 2 3 abcd'), - ) - - zone = Zone('unit.tests.', []) - a = TlsaRecord( - zone, - 'tlsa', - { - 'ttl': 32, - 'value': { - 'certificate_usage': 2, - 'selector': 1, - 'matching_type': 0, - 'certificate_association_data': 'abcd', - }, - }, - ) - self.assertEqual(2, a.values[0].certificate_usage) - self.assertEqual(1, a.values[0].selector) - self.assertEqual(0, a.values[0].matching_type) - self.assertEqual('abcd', a.values[0].certificate_association_data) - self.assertEqual('2 1 0 abcd', a.values[0].rdata_text) - - def test_txt(self): - a_values = ['a one', 'a two'] - b_value = 'b other' - self.assertMultipleValues(TxtRecord, a_values, b_value) - - def test_urlfwd(self): - a_values = [ - UrlfwdValue( - { - 'path': '/', - 'target': 'http://foo', - 'code': 301, - 'masking': 2, - 'query': 0, - } - ), - UrlfwdValue( - { - 'path': '/target', - 'target': 'http://target', - 'code': 302, - 'masking': 2, - 'query': 0, - } - ), - ] - a_data = {'ttl': 30, 'values': a_values} - a = UrlfwdRecord(self.zone, 'a', a_data) - self.assertEqual('a', a.name) - self.assertEqual('a.unit.tests.', a.fqdn) - self.assertEqual(30, a.ttl) - self.assertEqual(a_values[0]['path'], a.values[0].path) - self.assertEqual(a_values[0]['target'], a.values[0].target) - self.assertEqual(a_values[0]['code'], a.values[0].code) - self.assertEqual(a_values[0]['masking'], a.values[0].masking) - self.assertEqual(a_values[0]['query'], a.values[0].query) - self.assertEqual(a_values[1]['path'], a.values[1].path) - self.assertEqual(a_values[1]['target'], a.values[1].target) - self.assertEqual(a_values[1]['code'], a.values[1].code) - self.assertEqual(a_values[1]['masking'], a.values[1].masking) - self.assertEqual(a_values[1]['query'], a.values[1].query) - self.assertEqual(a_data, a.data) - - b_value = UrlfwdValue( - { - 'path': '/', - 'target': 'http://location', - 'code': 301, - 'masking': 2, - 'query': 0, - } - ) - b_data = {'ttl': 30, 'value': b_value} - b = UrlfwdRecord(self.zone, 'b', b_data) - self.assertEqual(b_value['path'], b.values[0].path) - self.assertEqual(b_value['target'], b.values[0].target) - self.assertEqual(b_value['code'], b.values[0].code) - self.assertEqual(b_value['masking'], b.values[0].masking) - self.assertEqual(b_value['query'], b.values[0].query) - self.assertEqual(b_data, b.data) - - target = SimpleProvider() - # No changes with self - self.assertFalse(a.changes(a, target)) - # Diff in path causes change - other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) - other.values[0].path = '/change' - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - # Diff in target causes change - other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) - other.values[0].target = 'http://target' - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - # Diff in code causes change - other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) - other.values[0].code = 302 - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - # Diff in masking causes change - other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) - other.values[0].masking = 0 - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - # Diff in query causes change - other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) - other.values[0].query = 1 - change = a.changes(other, target) - self.assertEqual(change.existing, a) - self.assertEqual(change.new, other) - - # hash - v = UrlfwdValue( - { - 'path': '/', - 'target': 'http://place', - 'code': 301, - 'masking': 2, - 'query': 0, - } - ) - o = UrlfwdValue( - { - 'path': '/location', - 'target': 'http://redirect', - 'code': 302, - 'masking': 2, - 'query': 0, - } - ) - values = set() - values.add(v) - self.assertTrue(v in values) - self.assertFalse(o in values) - values.add(o) - self.assertTrue(o in values) - - # __repr__ doesn't blow up - a.__repr__() - def test_record_new(self): txt = Record.new( self.zone, 'txt', {'ttl': 44, 'type': 'TXT', 'value': 'some text'} @@ -2127,21 +259,6 @@ class TestRecord(TestCase): d.copy() self.assertEqual('TXT', d._type) - def test_dynamic_record_copy(self): - a_data = { - 'dynamic': { - 'pools': {'one': {'values': [{'value': '3.3.3.3'}]}}, - 'rules': [{'pool': 'one'}], - }, - 'octodns': {'healthcheck': {'protocol': 'TCP', 'port': 80}}, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - record1 = Record.new(self.zone, 'a', a_data) - record2 = record1.copy() - self.assertEqual(record1._octodns, record2._octodns) - def test_change(self): existing = Record.new( self.zone, 'txt', {'ttl': 44, 'type': 'TXT', 'value': 'some text'} @@ -2156,138 +273,6 @@ class TestRecord(TestCase): delete = Delete(existing) self.assertEqual(existing.values, delete.record.values) - def test_geo_value(self): - code = 'NA-US-CA' - values = ['1.2.3.4'] - geo = GeoValue(code, values) - self.assertEqual(code, geo.code) - self.assertEqual('NA', geo.continent_code) - self.assertEqual('US', geo.country_code) - self.assertEqual('CA', geo.subdivision_code) - self.assertEqual(values, geo.values) - self.assertEqual(['NA-US', 'NA'], list(geo.parents)) - - a = GeoValue('NA-US-CA', values) - b = GeoValue('AP-JP', values) - c = GeoValue('NA-US-CA', ['2.3.4.5']) - - self.assertEqual(a, a) - self.assertEqual(b, b) - self.assertEqual(c, c) - - self.assertNotEqual(a, b) - self.assertNotEqual(a, c) - self.assertNotEqual(b, a) - self.assertNotEqual(b, c) - self.assertNotEqual(c, a) - self.assertNotEqual(c, b) - - self.assertTrue(a > b) - self.assertTrue(a < c) - self.assertTrue(b < a) - self.assertTrue(b < c) - self.assertTrue(c > a) - self.assertTrue(c > b) - - self.assertTrue(a >= a) - self.assertTrue(a >= b) - self.assertTrue(a <= c) - self.assertTrue(b <= a) - self.assertTrue(b <= b) - self.assertTrue(b <= c) - self.assertTrue(c > a) - self.assertTrue(c > b) - self.assertTrue(c >= b) - - def test_healthcheck(self): - new = Record.new( - self.zone, - 'a', - { - 'ttl': 44, - 'type': 'A', - 'value': '1.2.3.4', - 'octodns': { - 'healthcheck': { - 'path': '/_ready', - 'host': 'bleep.bloop', - 'protocol': 'HTTP', - 'port': 8080, - } - }, - }, - ) - self.assertEqual('/_ready', new.healthcheck_path) - self.assertEqual('bleep.bloop', new.healthcheck_host()) - self.assertEqual('HTTP', new.healthcheck_protocol) - self.assertEqual(8080, new.healthcheck_port) - - # empty host value in healthcheck - new = Record.new( - self.zone, - 'a', - { - 'ttl': 44, - 'type': 'A', - 'value': '1.2.3.4', - 'octodns': { - 'healthcheck': { - 'path': '/_ready', - 'host': None, - 'protocol': 'HTTP', - 'port': 8080, - } - }, - }, - ) - self.assertEqual('1.2.3.4', new.healthcheck_host(value="1.2.3.4")) - - new = Record.new( - self.zone, 'a', {'ttl': 44, 'type': 'A', 'value': '1.2.3.4'} - ) - self.assertEqual('/_dns', new.healthcheck_path) - self.assertEqual('a.unit.tests', new.healthcheck_host()) - self.assertEqual('HTTPS', new.healthcheck_protocol) - self.assertEqual(443, new.healthcheck_port) - - def test_healthcheck_tcp(self): - new = Record.new( - self.zone, - 'a', - { - 'ttl': 44, - 'type': 'A', - 'value': '1.2.3.4', - 'octodns': { - 'healthcheck': { - 'path': '/ignored', - 'host': 'completely.ignored', - 'protocol': 'TCP', - 'port': 8080, - } - }, - }, - ) - self.assertIsNone(new.healthcheck_path) - self.assertIsNone(new.healthcheck_host()) - self.assertEqual('TCP', new.healthcheck_protocol) - self.assertEqual(8080, new.healthcheck_port) - - new = Record.new( - self.zone, - 'a', - { - 'ttl': 44, - 'type': 'A', - 'value': '1.2.3.4', - 'octodns': {'healthcheck': {'protocol': 'TCP'}}, - }, - ) - self.assertIsNone(new.healthcheck_path) - self.assertIsNone(new.healthcheck_host()) - self.assertEqual('TCP', new.healthcheck_protocol) - self.assertEqual(443, new.healthcheck_port) - def test_inored(self): new = Record.new( self.zone, @@ -2384,387 +369,6 @@ class TestRecord(TestCase): self.assertTrue(aaaa <= c) self.assertTrue(aaaa <= aaaa) - def test_caa_value(self): - a = CaaValue({'flags': 0, 'tag': 'a', 'value': 'v'}) - b = CaaValue({'flags': 1, 'tag': 'a', 'value': 'v'}) - c = CaaValue({'flags': 0, 'tag': 'c', 'value': 'v'}) - d = CaaValue({'flags': 0, 'tag': 'a', 'value': 'z'}) - - self.assertEqual(a, a) - self.assertEqual(b, b) - self.assertEqual(c, c) - self.assertEqual(d, d) - - self.assertNotEqual(a, b) - self.assertNotEqual(a, c) - self.assertNotEqual(a, d) - self.assertNotEqual(b, a) - self.assertNotEqual(b, c) - self.assertNotEqual(b, d) - self.assertNotEqual(c, a) - self.assertNotEqual(c, b) - self.assertNotEqual(c, d) - - self.assertTrue(a < b) - self.assertTrue(a < c) - self.assertTrue(a < d) - - self.assertTrue(b > a) - self.assertTrue(b > c) - self.assertTrue(b > d) - - self.assertTrue(c > a) - self.assertTrue(c < b) - self.assertTrue(c > d) - - self.assertTrue(d > a) - self.assertTrue(d < b) - self.assertTrue(d < c) - - self.assertTrue(a <= b) - self.assertTrue(a <= c) - self.assertTrue(a <= d) - self.assertTrue(a <= a) - self.assertTrue(a >= a) - - self.assertTrue(b >= a) - self.assertTrue(b >= c) - self.assertTrue(b >= d) - self.assertTrue(b >= b) - self.assertTrue(b <= b) - - self.assertTrue(c >= a) - self.assertTrue(c <= b) - self.assertTrue(c >= d) - self.assertTrue(c >= c) - self.assertTrue(c <= c) - - self.assertTrue(d >= a) - self.assertTrue(d <= b) - self.assertTrue(d <= c) - self.assertTrue(d >= d) - self.assertTrue(d <= d) - - def test_loc_value(self): - a = LocValue( - { - 'lat_degrees': 31, - 'lat_minutes': 58, - 'lat_seconds': 52.1, - 'lat_direction': 'S', - 'long_degrees': 115, - 'long_minutes': 49, - 'long_seconds': 11.7, - 'long_direction': 'E', - 'altitude': 20, - 'size': 10, - 'precision_horz': 10, - 'precision_vert': 2, - } - ) - b = LocValue( - { - 'lat_degrees': 32, - 'lat_minutes': 7, - 'lat_seconds': 19, - 'lat_direction': 'S', - 'long_degrees': 116, - 'long_minutes': 2, - 'long_seconds': 25, - 'long_direction': 'E', - 'altitude': 10, - 'size': 1, - 'precision_horz': 10000, - 'precision_vert': 10, - } - ) - c = LocValue( - { - 'lat_degrees': 53, - 'lat_minutes': 14, - 'lat_seconds': 10, - 'lat_direction': 'N', - 'long_degrees': 2, - 'long_minutes': 18, - 'long_seconds': 26, - 'long_direction': 'W', - 'altitude': 10, - 'size': 1, - 'precision_horz': 1000, - 'precision_vert': 10, - } - ) - - self.assertEqual(a, a) - self.assertEqual(b, b) - self.assertEqual(c, c) - - self.assertNotEqual(a, b) - self.assertNotEqual(a, c) - self.assertNotEqual(b, a) - self.assertNotEqual(b, c) - self.assertNotEqual(c, a) - self.assertNotEqual(c, b) - - self.assertTrue(a < b) - self.assertTrue(a < c) - - self.assertTrue(b > a) - self.assertTrue(b < c) - - self.assertTrue(c > a) - self.assertTrue(c > b) - - self.assertTrue(a <= b) - self.assertTrue(a <= c) - self.assertTrue(a <= a) - self.assertTrue(a >= a) - - self.assertTrue(b >= a) - self.assertTrue(b <= c) - self.assertTrue(b >= b) - self.assertTrue(b <= b) - - self.assertTrue(c >= a) - self.assertTrue(c >= b) - self.assertTrue(c >= c) - self.assertTrue(c <= c) - - self.assertEqual(31, a.lat_degrees) - a.lat_degrees = a.lat_degrees + 1 - self.assertEqual(32, a.lat_degrees) - - self.assertEqual(58, a.lat_minutes) - a.lat_minutes = a.lat_minutes + 1 - self.assertEqual(59, a.lat_minutes) - - self.assertEqual(52.1, a.lat_seconds) - a.lat_seconds = a.lat_seconds + 1 - self.assertEqual(53.1, a.lat_seconds) - - self.assertEqual('S', a.lat_direction) - a.lat_direction = 'N' - self.assertEqual('N', a.lat_direction) - - self.assertEqual(115, a.long_degrees) - a.long_degrees = a.long_degrees + 1 - self.assertEqual(116, a.long_degrees) - - self.assertEqual(49, a.long_minutes) - a.long_minutes = a.long_minutes + 1 - self.assertEqual(50, a.long_minutes) - - self.assertEqual(11.7, a.long_seconds) - a.long_seconds = a.long_seconds + 1 - self.assertEqual(12.7, a.long_seconds) - - self.assertEqual('E', a.long_direction) - a.long_direction = 'W' - self.assertEqual('W', a.long_direction) - - self.assertEqual(20, a.altitude) - a.altitude = a.altitude + 1 - self.assertEqual(21, a.altitude) - - self.assertEqual(10, a.size) - a.size = a.size + 1 - self.assertEqual(11, a.size) - - self.assertEqual(10, a.precision_horz) - a.precision_horz = a.precision_horz + 1 - self.assertEqual(11, a.precision_horz) - - self.assertEqual(2, a.precision_vert) - a.precision_vert = a.precision_vert + 1 - self.assertEqual(3, a.precision_vert) - - # Hash - values = set() - values.add(a) - self.assertTrue(a in values) - self.assertFalse(b in values) - values.add(b) - self.assertTrue(b in values) - - def test_mx_value(self): - a = MxValue( - {'preference': 0, 'priority': 'a', 'exchange': 'v', 'value': '1'} - ) - b = MxValue( - {'preference': 10, 'priority': 'a', 'exchange': 'v', 'value': '2'} - ) - c = MxValue( - {'preference': 0, 'priority': 'b', 'exchange': 'z', 'value': '3'} - ) - - self.assertEqual(a, a) - self.assertEqual(b, b) - self.assertEqual(c, c) - - self.assertNotEqual(a, b) - self.assertNotEqual(a, c) - self.assertNotEqual(b, a) - self.assertNotEqual(b, c) - self.assertNotEqual(c, a) - self.assertNotEqual(c, b) - - self.assertTrue(a < b) - self.assertTrue(a < c) - - self.assertTrue(b > a) - self.assertTrue(b > c) - - self.assertTrue(c > a) - self.assertTrue(c < b) - - self.assertTrue(a <= b) - self.assertTrue(a <= c) - self.assertTrue(a <= a) - self.assertTrue(a >= a) - - self.assertTrue(b >= a) - self.assertTrue(b >= c) - self.assertTrue(b >= b) - self.assertTrue(b <= b) - - self.assertTrue(c >= a) - self.assertTrue(c <= b) - self.assertTrue(c >= c) - self.assertTrue(c <= c) - - self.assertEqual(a.__hash__(), a.__hash__()) - self.assertNotEqual(a.__hash__(), b.__hash__()) - - def test_sshfp_value(self): - a = SshfpValue( - {'algorithm': 0, 'fingerprint_type': 0, 'fingerprint': 'abcd'} - ) - b = SshfpValue( - {'algorithm': 1, 'fingerprint_type': 0, 'fingerprint': 'abcd'} - ) - c = SshfpValue( - {'algorithm': 0, 'fingerprint_type': 1, 'fingerprint': 'abcd'} - ) - d = SshfpValue( - {'algorithm': 0, 'fingerprint_type': 0, 'fingerprint': 'bcde'} - ) - - self.assertEqual(a, a) - self.assertEqual(b, b) - self.assertEqual(c, c) - self.assertEqual(d, d) - - self.assertNotEqual(a, b) - self.assertNotEqual(a, c) - self.assertNotEqual(a, d) - self.assertNotEqual(b, a) - self.assertNotEqual(b, c) - self.assertNotEqual(b, d) - self.assertNotEqual(c, a) - self.assertNotEqual(c, b) - self.assertNotEqual(c, d) - self.assertNotEqual(d, a) - self.assertNotEqual(d, b) - self.assertNotEqual(d, c) - - self.assertTrue(a < b) - self.assertTrue(a < c) - - self.assertTrue(b > a) - self.assertTrue(b > c) - - self.assertTrue(c > a) - self.assertTrue(c < b) - - self.assertTrue(a <= b) - self.assertTrue(a <= c) - self.assertTrue(a <= a) - self.assertTrue(a >= a) - - self.assertTrue(b >= a) - self.assertTrue(b >= c) - self.assertTrue(b >= b) - self.assertTrue(b <= b) - - self.assertTrue(c >= a) - self.assertTrue(c <= b) - self.assertTrue(c >= c) - self.assertTrue(c <= c) - - # Hash - values = set() - values.add(a) - self.assertTrue(a in values) - self.assertFalse(b in values) - values.add(b) - self.assertTrue(b in values) - - def test_srv_value(self): - a = SrvValue({'priority': 0, 'weight': 0, 'port': 0, 'target': 'foo.'}) - b = SrvValue({'priority': 1, 'weight': 0, 'port': 0, 'target': 'foo.'}) - c = SrvValue({'priority': 0, 'weight': 2, 'port': 0, 'target': 'foo.'}) - d = SrvValue({'priority': 0, 'weight': 0, 'port': 3, 'target': 'foo.'}) - e = SrvValue({'priority': 0, 'weight': 0, 'port': 0, 'target': 'mmm.'}) - - self.assertEqual(a, a) - self.assertEqual(b, b) - self.assertEqual(c, c) - self.assertEqual(d, d) - self.assertEqual(e, e) - - self.assertNotEqual(a, b) - self.assertNotEqual(a, c) - self.assertNotEqual(a, d) - self.assertNotEqual(a, e) - self.assertNotEqual(b, a) - self.assertNotEqual(b, c) - self.assertNotEqual(b, d) - self.assertNotEqual(b, e) - self.assertNotEqual(c, a) - self.assertNotEqual(c, b) - self.assertNotEqual(c, d) - self.assertNotEqual(c, e) - self.assertNotEqual(d, a) - self.assertNotEqual(d, b) - self.assertNotEqual(d, c) - self.assertNotEqual(d, e) - self.assertNotEqual(e, a) - self.assertNotEqual(e, b) - self.assertNotEqual(e, c) - self.assertNotEqual(e, d) - - self.assertTrue(a < b) - self.assertTrue(a < c) - - self.assertTrue(b > a) - self.assertTrue(b > c) - - self.assertTrue(c > a) - self.assertTrue(c < b) - - self.assertTrue(a <= b) - self.assertTrue(a <= c) - self.assertTrue(a <= a) - self.assertTrue(a >= a) - - self.assertTrue(b >= a) - self.assertTrue(b >= c) - self.assertTrue(b >= b) - self.assertTrue(b <= b) - - self.assertTrue(c >= a) - self.assertTrue(c <= b) - self.assertTrue(c >= c) - self.assertTrue(c <= c) - - # Hash - values = set() - values.add(a) - self.assertTrue(a in values) - self.assertFalse(b in values) - values.add(b) - self.assertTrue(b in values) - def test_rr(self): # nothing much to test, just make sure that things don't blow up Rr('name', 'type', 42, 'Hello World!').__repr__() @@ -2931,3478 +535,3 @@ class TestRecordValidation(TestCase): }, lenient=True, ) - - def test_A_and_values_mixin(self): - # doesn't blow up - Record.new(self.zone, '', {'type': 'A', 'ttl': 600, 'value': '1.2.3.4'}) - Record.new( - self.zone, '', {'type': 'A', 'ttl': 600, 'values': ['1.2.3.4']} - ) - Record.new( - self.zone, - '', - {'type': 'A', 'ttl': 600, 'values': ['1.2.3.4', '1.2.3.5']}, - ) - - # missing value(s), no value or value - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, '', {'type': 'A', 'ttl': 600}) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) - - # missing value(s), empty values - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, 'www', {'type': 'A', 'ttl': 600, 'values': []} - ) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) - - # missing value(s), None values - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, 'www', {'type': 'A', 'ttl': 600, 'values': None} - ) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) - - # missing value(s) and empty value - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - 'www', - {'type': 'A', 'ttl': 600, 'values': [None, '']}, - ) - self.assertEqual( - ['missing value(s)', 'empty value'], ctx.exception.reasons - ) - - # missing value(s), None value - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, 'www', {'type': 'A', 'ttl': 600, 'value': None} - ) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) - - # empty value, empty string value - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'www', {'type': 'A', 'ttl': 600, 'value': ''}) - self.assertEqual(['empty value'], ctx.exception.reasons) - - # missing value(s) & ttl - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, '', {'type': 'A'}) - self.assertEqual( - ['missing ttl', 'missing value(s)'], ctx.exception.reasons - ) - - # invalid ipv4 address - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, '', {'type': 'A', 'ttl': 600, 'value': 'hello'} - ) - self.assertEqual( - ['invalid IPv4 address "hello"'], ctx.exception.reasons - ) - - # invalid ipv4 addresses - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - {'type': 'A', 'ttl': 600, 'values': ['hello', 'goodbye']}, - ) - self.assertEqual( - ['invalid IPv4 address "hello"', 'invalid IPv4 address "goodbye"'], - ctx.exception.reasons, - ) - - # invalid & valid ipv4 addresses, no ttl - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - {'type': 'A', 'values': ['1.2.3.4', 'hello', '5.6.7.8']}, - ) - self.assertEqual( - ['missing ttl', 'invalid IPv4 address "hello"'], - ctx.exception.reasons, - ) - - def test_AAAA_validation(self): - # doesn't blow up - Record.new( - self.zone, - '', - { - 'type': 'AAAA', - 'ttl': 600, - 'value': '2601:644:500:e210:62f8:1dff:feb8:947a', - }, - ) - Record.new( - self.zone, - '', - { - 'type': 'AAAA', - 'ttl': 600, - 'values': ['2601:644:500:e210:62f8:1dff:feb8:947a'], - }, - ) - Record.new( - self.zone, - '', - { - 'type': 'AAAA', - 'ttl': 600, - 'values': [ - '2601:644:500:e210:62f8:1dff:feb8:947a', - '2601:642:500:e210:62f8:1dff:feb8:947a', - ], - }, - ) - - # missing value(s), no value or value - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, '', {'type': 'AAAA', 'ttl': 600}) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) - - # missing value(s), empty values - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, 'www', {'type': 'AAAA', 'ttl': 600, 'values': []} - ) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) - - # missing value(s), None values - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, 'www', {'type': 'AAAA', 'ttl': 600, 'values': None} - ) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) - - # missing value(s) and empty value - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - 'www', - {'type': 'AAAA', 'ttl': 600, 'values': [None, '']}, - ) - self.assertEqual( - ['missing value(s)', 'empty value'], ctx.exception.reasons - ) - - # missing value(s), None value - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, 'www', {'type': 'AAAA', 'ttl': 600, 'value': None} - ) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) - - # empty value, empty string value - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, 'www', {'type': 'AAAA', 'ttl': 600, 'value': ''} - ) - self.assertEqual(['empty value'], ctx.exception.reasons) - - # missing value(s) & ttl - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, '', {'type': 'AAAA'}) - self.assertEqual( - ['missing ttl', 'missing value(s)'], ctx.exception.reasons - ) - - # invalid IPv6 address - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, '', {'type': 'AAAA', 'ttl': 600, 'value': 'hello'} - ) - self.assertEqual( - ['invalid IPv6 address "hello"'], ctx.exception.reasons - ) - - # invalid IPv6 addresses - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - {'type': 'AAAA', 'ttl': 600, 'values': ['hello', 'goodbye']}, - ) - self.assertEqual( - ['invalid IPv6 address "hello"', 'invalid IPv6 address "goodbye"'], - ctx.exception.reasons, - ) - - # invalid & valid IPv6 addresses, no ttl - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'AAAA', - 'values': [ - '2601:644:500:e210:62f8:1dff:feb8:947a', - 'hello', - '2601:642:500:e210:62f8:1dff:feb8:947a', - ], - }, - ) - self.assertEqual( - ['missing ttl', 'invalid IPv6 address "hello"'], - ctx.exception.reasons, - ) - - def test_geo(self): - Record.new( - self.zone, - '', - { - 'geo': {'NA': ['1.2.3.5'], 'NA-US': ['1.2.3.5', '1.2.3.6']}, - 'type': 'A', - 'ttl': 600, - 'value': '1.2.3.4', - }, - ) - - # invalid ip address - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'geo': {'NA': ['hello'], 'NA-US': ['1.2.3.5', '1.2.3.6']}, - 'type': 'A', - 'ttl': 600, - 'value': '1.2.3.4', - }, - ) - self.assertEqual( - ['invalid IPv4 address "hello"'], ctx.exception.reasons - ) - - # invalid geo code - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'geo': {'XYZ': ['1.2.3.4']}, - 'type': 'A', - 'ttl': 600, - 'value': '1.2.3.4', - }, - ) - self.assertEqual(['invalid geo "XYZ"'], ctx.exception.reasons) - - # invalid ip address - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'geo': {'NA': ['hello'], 'NA-US': ['1.2.3.5', 'goodbye']}, - 'type': 'A', - 'ttl': 600, - 'value': '1.2.3.4', - }, - ) - self.assertEqual( - ['invalid IPv4 address "hello"', 'invalid IPv4 address "goodbye"'], - ctx.exception.reasons, - ) - - # invalid healthcheck protocol - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - 'a', - { - 'geo': {'NA': ['1.2.3.5'], 'NA-US': ['1.2.3.5', '1.2.3.6']}, - 'type': 'A', - 'ttl': 600, - 'value': '1.2.3.4', - 'octodns': {'healthcheck': {'protocol': 'FTP'}}, - }, - ) - self.assertEqual( - ['invalid healthcheck protocol'], ctx.exception.reasons - ) - - def test_AAAA(self): - # doesn't blow up - Record.new( - self.zone, - '', - { - 'type': 'AAAA', - 'ttl': 600, - 'value': '2601:644:500:e210:62f8:1dff:feb8:947a', - }, - ) - Record.new( - self.zone, - '', - { - 'type': 'AAAA', - 'ttl': 600, - 'values': [ - '2601:644:500:e210:62f8:1dff:feb8:947a', - '2601:644:500:e210:62f8:1dff:feb8:947b', - ], - }, - ) - - # invalid ip address - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, '', {'type': 'AAAA', 'ttl': 600, 'value': 'hello'} - ) - self.assertEqual( - ['invalid IPv6 address "hello"'], ctx.exception.reasons - ) - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - {'type': 'AAAA', 'ttl': 600, 'values': ['1.2.3.4', '2.3.4.5']}, - ) - self.assertEqual( - [ - 'invalid IPv6 address "1.2.3.4"', - 'invalid IPv6 address "2.3.4.5"', - ], - ctx.exception.reasons, - ) - - # invalid ip addresses - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - {'type': 'AAAA', 'ttl': 600, 'values': ['hello', 'goodbye']}, - ) - self.assertEqual( - ['invalid IPv6 address "hello"', 'invalid IPv6 address "goodbye"'], - ctx.exception.reasons, - ) - - def test_ALIAS_and_value_mixin(self): - # doesn't blow up - Record.new( - self.zone, - '', - {'type': 'ALIAS', 'ttl': 600, 'value': 'foo.bar.com.'}, - ) - - # root only - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - 'nope', - {'type': 'ALIAS', 'ttl': 600, 'value': 'foo.bar.com.'}, - ) - self.assertEqual(['non-root ALIAS not allowed'], ctx.exception.reasons) - - # missing value - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, '', {'type': 'ALIAS', 'ttl': 600}) - self.assertEqual(['missing value'], ctx.exception.reasons) - - # missing value - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, '', {'type': 'ALIAS', 'ttl': 600, 'value': None} - ) - self.assertEqual(['missing value'], ctx.exception.reasons) - - # empty value - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, '', {'type': 'ALIAS', 'ttl': 600, 'value': ''} - ) - self.assertEqual(['empty value'], ctx.exception.reasons) - - # not a valid FQDN - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, '', {'type': 'ALIAS', 'ttl': 600, 'value': '__.'} - ) - self.assertEqual( - ['ALIAS value "__." is not a valid FQDN'], ctx.exception.reasons - ) - - # missing trailing . - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - {'type': 'ALIAS', 'ttl': 600, 'value': 'foo.bar.com'}, - ) - self.assertEqual( - ['ALIAS value "foo.bar.com" missing trailing .'], - ctx.exception.reasons, - ) - - def test_CAA(self): - # doesn't blow up - Record.new( - self.zone, - '', - { - 'type': 'CAA', - 'ttl': 600, - 'value': { - 'flags': 128, - 'tag': 'iodef', - 'value': 'http://foo.bar.com/', - }, - }, - ) - - # invalid flags - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'CAA', - 'ttl': 600, - 'value': { - 'flags': -42, - 'tag': 'iodef', - 'value': 'http://foo.bar.com/', - }, - }, - ) - self.assertEqual(['invalid flags "-42"'], ctx.exception.reasons) - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'CAA', - 'ttl': 600, - 'value': { - 'flags': 442, - 'tag': 'iodef', - 'value': 'http://foo.bar.com/', - }, - }, - ) - self.assertEqual(['invalid flags "442"'], ctx.exception.reasons) - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'CAA', - 'ttl': 600, - 'value': { - 'flags': 'nope', - 'tag': 'iodef', - 'value': 'http://foo.bar.com/', - }, - }, - ) - self.assertEqual(['invalid flags "nope"'], ctx.exception.reasons) - - # missing tag - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'CAA', - 'ttl': 600, - 'value': {'value': 'http://foo.bar.com/'}, - }, - ) - self.assertEqual(['missing tag'], ctx.exception.reasons) - - # missing value - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - {'type': 'CAA', 'ttl': 600, 'value': {'tag': 'iodef'}}, - ) - self.assertEqual(['missing value'], ctx.exception.reasons) - - def test_cname_validation(self): - # doesn't blow up - Record.new( - self.zone, - 'www', - {'type': 'CNAME', 'ttl': 600, 'value': 'foo.bar.com.'}, - ) - - # root cname is a no-no - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - {'type': 'CNAME', 'ttl': 600, 'value': 'foo.bar.com.'}, - ) - self.assertEqual(['root CNAME not allowed'], ctx.exception.reasons) - - # not a valid FQDN - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, 'www', {'type': 'CNAME', 'ttl': 600, 'value': '___.'} - ) - self.assertEqual( - ['CNAME value "___." is not a valid FQDN'], ctx.exception.reasons - ) - - # missing trailing . - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - 'www', - {'type': 'CNAME', 'ttl': 600, 'value': 'foo.bar.com'}, - ) - self.assertEqual( - ['CNAME value "foo.bar.com" missing trailing .'], - ctx.exception.reasons, - ) - - # doesn't allow urls - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - 'www', - {'type': 'CNAME', 'ttl': 600, 'value': 'https://google.com'}, - ) - self.assertEqual( - ['CNAME value "https://google.com" is not a valid FQDN'], - ctx.exception.reasons, - ) - - # doesn't allow urls with paths - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - 'www', - { - 'type': 'CNAME', - 'ttl': 600, - 'value': 'https://google.com/a/b/c', - }, - ) - self.assertEqual( - ['CNAME value "https://google.com/a/b/c" is not a valid FQDN'], - ctx.exception.reasons, - ) - - # doesn't allow paths - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - 'www', - {'type': 'CNAME', 'ttl': 600, 'value': 'google.com/some/path'}, - ) - self.assertEqual( - ['CNAME value "google.com/some/path" is not a valid FQDN'], - ctx.exception.reasons, - ) - - def test_DNAME(self): - # A valid DNAME record. - Record.new( - self.zone, - 'sub', - {'type': 'DNAME', 'ttl': 600, 'value': 'foo.bar.com.'}, - ) - - # A DNAME record can be present at the zone APEX. - Record.new( - self.zone, - '', - {'type': 'DNAME', 'ttl': 600, 'value': 'foo.bar.com.'}, - ) - - # not a valid FQDN - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, 'www', {'type': 'DNAME', 'ttl': 600, 'value': '.'} - ) - self.assertEqual( - ['DNAME value "." is not a valid FQDN'], ctx.exception.reasons - ) - - # missing trailing . - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - 'www', - {'type': 'DNAME', 'ttl': 600, 'value': 'foo.bar.com'}, - ) - self.assertEqual( - ['DNAME value "foo.bar.com" missing trailing .'], - ctx.exception.reasons, - ) - - def test_LOC(self): - # doesn't blow up - Record.new( - self.zone, - '', - { - 'type': 'LOC', - 'ttl': 600, - 'value': { - 'lat_degrees': 31, - 'lat_minutes': 58, - 'lat_seconds': 52.1, - 'lat_direction': 'S', - 'long_degrees': 115, - 'long_minutes': 49, - 'long_seconds': 11.7, - 'long_direction': 'E', - 'altitude': 20, - 'size': 10, - 'precision_horz': 10, - 'precision_vert': 2, - }, - }, - ) - - # missing int key - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'LOC', - 'ttl': 600, - 'value': { - 'lat_minutes': 58, - 'lat_seconds': 52.1, - 'lat_direction': 'S', - 'long_degrees': 115, - 'long_minutes': 49, - 'long_seconds': 11.7, - 'long_direction': 'E', - 'altitude': 20, - 'size': 10, - 'precision_horz': 10, - 'precision_vert': 2, - }, - }, - ) - - self.assertEqual(['missing lat_degrees'], ctx.exception.reasons) - - # missing float key - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'LOC', - 'ttl': 600, - 'value': { - 'lat_degrees': 31, - 'lat_minutes': 58, - 'lat_direction': 'S', - 'long_degrees': 115, - 'long_minutes': 49, - 'long_seconds': 11.7, - 'long_direction': 'E', - 'altitude': 20, - 'size': 10, - 'precision_horz': 10, - 'precision_vert': 2, - }, - }, - ) - - self.assertEqual(['missing lat_seconds'], ctx.exception.reasons) - - # missing text key - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'LOC', - 'ttl': 600, - 'value': { - 'lat_degrees': 31, - 'lat_minutes': 58, - 'lat_seconds': 52.1, - 'long_degrees': 115, - 'long_minutes': 49, - 'long_seconds': 11.7, - 'long_direction': 'E', - 'altitude': 20, - 'size': 10, - 'precision_horz': 10, - 'precision_vert': 2, - }, - }, - ) - - self.assertEqual(['missing lat_direction'], ctx.exception.reasons) - - # invalid direction - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'LOC', - 'ttl': 600, - 'value': { - 'lat_degrees': 31, - 'lat_minutes': 58, - 'lat_seconds': 52.1, - 'lat_direction': 'U', - 'long_degrees': 115, - 'long_minutes': 49, - 'long_seconds': 11.7, - 'long_direction': 'E', - 'altitude': 20, - 'size': 10, - 'precision_horz': 10, - 'precision_vert': 2, - }, - }, - ) - - self.assertEqual( - ['invalid direction for lat_direction "U"'], ctx.exception.reasons - ) - - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'LOC', - 'ttl': 600, - 'value': { - 'lat_degrees': 31, - 'lat_minutes': 58, - 'lat_seconds': 52.1, - 'lat_direction': 'S', - 'long_degrees': 115, - 'long_minutes': 49, - 'long_seconds': 11.7, - 'long_direction': 'N', - 'altitude': 20, - 'size': 10, - 'precision_horz': 10, - 'precision_vert': 2, - }, - }, - ) - - self.assertEqual( - ['invalid direction for long_direction "N"'], ctx.exception.reasons - ) - - # invalid degrees - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'LOC', - 'ttl': 600, - 'value': { - 'lat_degrees': 360, - 'lat_minutes': 58, - 'lat_seconds': 52.1, - 'lat_direction': 'S', - 'long_degrees': 115, - 'long_minutes': 49, - 'long_seconds': 11.7, - 'long_direction': 'E', - 'altitude': 20, - 'size': 10, - 'precision_horz': 10, - 'precision_vert': 2, - }, - }, - ) - - self.assertEqual( - ['invalid value for lat_degrees "360"'], ctx.exception.reasons - ) - - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'LOC', - 'ttl': 600, - 'value': { - 'lat_degrees': 'nope', - 'lat_minutes': 58, - 'lat_seconds': 52.1, - 'lat_direction': 'S', - 'long_degrees': 115, - 'long_minutes': 49, - 'long_seconds': 11.7, - 'long_direction': 'E', - 'altitude': 20, - 'size': 10, - 'precision_horz': 10, - 'precision_vert': 2, - }, - }, - ) - - self.assertEqual(['invalid lat_degrees "nope"'], ctx.exception.reasons) - - # invalid minutes - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'LOC', - 'ttl': 600, - 'value': { - 'lat_degrees': 31, - 'lat_minutes': 60, - 'lat_seconds': 52.1, - 'lat_direction': 'S', - 'long_degrees': 115, - 'long_minutes': 49, - 'long_seconds': 11.7, - 'long_direction': 'E', - 'altitude': 20, - 'size': 10, - 'precision_horz': 10, - 'precision_vert': 2, - }, - }, - ) - - self.assertEqual( - ['invalid value for lat_minutes "60"'], ctx.exception.reasons - ) - - # invalid seconds - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'LOC', - 'ttl': 600, - 'value': { - 'lat_degrees': 31, - 'lat_minutes': 58, - 'lat_seconds': 60, - 'lat_direction': 'S', - 'long_degrees': 115, - 'long_minutes': 49, - 'long_seconds': 11.7, - 'long_direction': 'E', - 'altitude': 20, - 'size': 10, - 'precision_horz': 10, - 'precision_vert': 2, - }, - }, - ) - - self.assertEqual( - ['invalid value for lat_seconds "60"'], ctx.exception.reasons - ) - - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'LOC', - 'ttl': 600, - 'value': { - 'lat_degrees': 31, - 'lat_minutes': 58, - 'lat_seconds': 'nope', - 'lat_direction': 'S', - 'long_degrees': 115, - 'long_minutes': 49, - 'long_seconds': 11.7, - 'long_direction': 'E', - 'altitude': 20, - 'size': 10, - 'precision_horz': 10, - 'precision_vert': 2, - }, - }, - ) - - self.assertEqual(['invalid lat_seconds "nope"'], ctx.exception.reasons) - - # invalid altitude - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'LOC', - 'ttl': 600, - 'value': { - 'lat_degrees': 31, - 'lat_minutes': 58, - 'lat_seconds': 52.1, - 'lat_direction': 'S', - 'long_degrees': 115, - 'long_minutes': 49, - 'long_seconds': 11.7, - 'long_direction': 'E', - 'altitude': -666666, - 'size': 10, - 'precision_horz': 10, - 'precision_vert': 2, - }, - }, - ) - - self.assertEqual( - ['invalid value for altitude "-666666"'], ctx.exception.reasons - ) - - # invalid size - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'LOC', - 'ttl': 600, - 'value': { - 'lat_degrees': 31, - 'lat_minutes': 58, - 'lat_seconds': 52.1, - 'lat_direction': 'S', - 'long_degrees': 115, - 'long_minutes': 49, - 'long_seconds': 11.7, - 'long_direction': 'E', - 'altitude': 20, - 'size': 99999999.99, - 'precision_horz': 10, - 'precision_vert': 2, - }, - }, - ) - - self.assertEqual( - ['invalid value for size "99999999.99"'], ctx.exception.reasons - ) - - def test_MX(self): - # doesn't blow up - Record.new( - self.zone, - '', - { - 'type': 'MX', - 'ttl': 600, - 'value': {'preference': 10, 'exchange': 'foo.bar.com.'}, - }, - ) - - # missing preference - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'MX', - 'ttl': 600, - 'value': {'exchange': 'foo.bar.com.'}, - }, - ) - self.assertEqual(['missing preference'], ctx.exception.reasons) - - # invalid preference - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'MX', - 'ttl': 600, - 'value': {'preference': 'nope', 'exchange': 'foo.bar.com.'}, - }, - ) - self.assertEqual(['invalid preference "nope"'], ctx.exception.reasons) - - # missing exchange - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - {'type': 'MX', 'ttl': 600, 'value': {'preference': 10}}, - ) - self.assertEqual(['missing exchange'], ctx.exception.reasons) - - # missing trailing . - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'MX', - 'ttl': 600, - 'value': {'preference': 10, 'exchange': 'foo.bar.com'}, - }, - ) - self.assertEqual( - ['MX value "foo.bar.com" missing trailing .'], ctx.exception.reasons - ) - - # exchange must be a valid FQDN - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'MX', - 'ttl': 600, - 'value': {'preference': 10, 'exchange': '100 foo.bar.com.'}, - }, - ) - self.assertEqual( - ['Invalid MX exchange "100 foo.bar.com." is not a valid FQDN.'], - ctx.exception.reasons, - ) - - # if exchange doesn't exist value can not be None/falsey - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'MX', - 'ttl': 600, - 'value': {'preference': 10, 'value': ''}, - }, - ) - self.assertEqual(['missing exchange'], ctx.exception.reasons) - - # exchange can be a single `.` - record = Record.new( - self.zone, - '', - { - 'type': 'MX', - 'ttl': 600, - 'value': {'preference': 0, 'exchange': '.'}, - }, - ) - self.assertEqual('.', record.values[0].exchange) - - def test_NAPTR(self): - # doesn't blow up - Record.new( - self.zone, - '', - { - 'type': 'NAPTR', - 'ttl': 600, - 'value': { - 'order': 10, - 'preference': 20, - 'flags': 'S', - 'service': 'srv', - 'regexp': '.*', - 'replacement': '.', - }, - }, - ) - - # missing X priority - value = { - 'order': 10, - 'preference': 20, - 'flags': 'S', - 'service': 'srv', - 'regexp': '.*', - 'replacement': '.', - } - for k in ( - 'order', - 'preference', - 'flags', - 'service', - 'regexp', - 'replacement', - ): - v = dict(value) - del v[k] - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, '', {'type': 'NAPTR', 'ttl': 600, 'value': v} - ) - self.assertEqual([f'missing {k}'], ctx.exception.reasons) - - # non-int order - v = dict(value) - v['order'] = 'boo' - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, '', {'type': 'NAPTR', 'ttl': 600, 'value': v}) - self.assertEqual(['invalid order "boo"'], ctx.exception.reasons) - - # non-int preference - v = dict(value) - v['preference'] = 'who' - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, '', {'type': 'NAPTR', 'ttl': 600, 'value': v}) - self.assertEqual(['invalid preference "who"'], ctx.exception.reasons) - - # unrecognized flags - v = dict(value) - v['flags'] = 'X' - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, '', {'type': 'NAPTR', 'ttl': 600, 'value': v}) - self.assertEqual(['unrecognized flags "X"'], ctx.exception.reasons) - - def test_NS(self): - # doesn't blow up - Record.new( - self.zone, - '', - {'type': 'NS', 'ttl': 600, 'values': ['foo.bar.com.', '1.2.3.4.']}, - ) - - # missing value - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, '', {'type': 'NS', 'ttl': 600}) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) - - # no trailing . - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, '', {'type': 'NS', 'ttl': 600, 'value': 'foo.bar'} - ) - self.assertEqual( - ['NS value "foo.bar" missing trailing .'], ctx.exception.reasons - ) - - # exchange must be a valid FQDN - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - {'type': 'NS', 'ttl': 600, 'value': '100 foo.bar.com.'}, - ) - self.assertEqual( - ['Invalid NS value "100 foo.bar.com." is not a valid FQDN.'], - ctx.exception.reasons, - ) - - def test_ptr(self): - # doesn't blow up (name & zone here don't make any sense, but not - # important) - Record.new( - self.zone, '', {'type': 'PTR', 'ttl': 600, 'value': 'foo.bar.com.'} - ) - - # missing value - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, '', {'type': 'PTR', 'ttl': 600}) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) - - # empty value - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, '', {'type': 'PTR', 'ttl': 600, 'value': ''}) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) - - # not a valid FQDN - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, '', {'type': 'PTR', 'ttl': 600, 'value': '_.'} - ) - self.assertEqual( - ['Invalid PTR value "_." is not a valid FQDN.'], - ctx.exception.reasons, - ) - - # no trailing . - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, '', {'type': 'PTR', 'ttl': 600, 'value': 'foo.bar'} - ) - self.assertEqual( - ['PTR value "foo.bar" missing trailing .'], ctx.exception.reasons - ) - - def test_ptr_rdata_text(self): - - # anything goes, we're a noop - for s in ( - None, - '', - 'word', - 42, - 42.43, - '1.2.3', - 'some.words.that.here', - '1.2.word.4', - '1.2.3.4', - ): - self.assertEqual(s, PtrValue.parse_rdata_text(s)) - - zone = Zone('unit.tests.', []) - a = PtrRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) - self.assertEqual('some.target.', a.values[0].rdata_text) - - a = PtrRecord( - zone, 'a', {'ttl': 42, 'values': ['some.target.', 'second.target.']} - ) - self.assertEqual('second.target.', a.values[0].rdata_text) - self.assertEqual('some.target.', a.values[1].rdata_text) - - def test_SSHFP(self): - # doesn't blow up - Record.new( - self.zone, - '', - { - 'type': 'SSHFP', - 'ttl': 600, - 'value': { - 'algorithm': 1, - 'fingerprint_type': 1, - 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', - }, - }, - ) - - # missing algorithm - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'SSHFP', - 'ttl': 600, - 'value': { - 'fingerprint_type': 1, - 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', - }, - }, - ) - self.assertEqual(['missing algorithm'], ctx.exception.reasons) - - # invalid algorithm - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'SSHFP', - 'ttl': 600, - 'value': { - 'algorithm': 'nope', - 'fingerprint_type': 2, - 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', - }, - }, - ) - self.assertEqual(['invalid algorithm "nope"'], ctx.exception.reasons) - - # unrecognized algorithm - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'SSHFP', - 'ttl': 600, - 'value': { - 'algorithm': 42, - 'fingerprint_type': 1, - 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', - }, - }, - ) - self.assertEqual(['unrecognized algorithm "42"'], ctx.exception.reasons) - - # missing fingerprint_type - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'SSHFP', - 'ttl': 600, - 'value': { - 'algorithm': 2, - 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', - }, - }, - ) - self.assertEqual(['missing fingerprint_type'], ctx.exception.reasons) - - # invalid fingerprint_type - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'SSHFP', - 'ttl': 600, - 'value': { - 'algorithm': 3, - 'fingerprint_type': 'yeeah', - 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', - }, - }, - ) - self.assertEqual( - ['invalid fingerprint_type "yeeah"'], ctx.exception.reasons - ) - - # unrecognized fingerprint_type - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'SSHFP', - 'ttl': 600, - 'value': { - 'algorithm': 1, - 'fingerprint_type': 42, - 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', - }, - }, - ) - self.assertEqual( - ['unrecognized fingerprint_type "42"'], ctx.exception.reasons - ) - - # missing fingerprint - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'SSHFP', - 'ttl': 600, - 'value': {'algorithm': 1, 'fingerprint_type': 1}, - }, - ) - self.assertEqual(['missing fingerprint'], ctx.exception.reasons) - - def test_SPF(self): - # doesn't blow up (name & zone here don't make any sense, but not - # important) - Record.new( - self.zone, - '', - { - 'type': 'SPF', - 'ttl': 600, - 'values': [ - 'v=spf1 ip4:192.168.0.1/16-all', - 'v=spf1 ip4:10.1.2.1/24-all', - 'this has some\\; semi-colons\\; in it', - ], - }, - ) - - # missing value - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, '', {'type': 'SPF', 'ttl': 600}) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) - - # missing escapes - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'SPF', - 'ttl': 600, - 'value': 'this has some; semi-colons\\; in it', - }, - ) - self.assertEqual( - ['unescaped ; in "this has some; semi-colons\\; in it"'], - ctx.exception.reasons, - ) - - def test_SRV(self): - # doesn't blow up - Record.new( - self.zone, - '_srv._tcp', - { - 'type': 'SRV', - 'ttl': 600, - 'value': { - 'priority': 1, - 'weight': 2, - 'port': 3, - 'target': 'foo.bar.baz.', - }, - }, - ) - - # permit wildcard entries - Record.new( - self.zone, - '*._tcp', - { - 'type': 'SRV', - 'ttl': 600, - 'value': { - 'priority': 1, - 'weight': 2, - 'port': 3, - 'target': 'food.bar.baz.', - }, - }, - ) - - # invalid name - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - 'neup', - { - 'type': 'SRV', - 'ttl': 600, - 'value': { - 'priority': 1, - 'weight': 2, - 'port': 3, - 'target': 'foo.bar.baz.', - }, - }, - ) - self.assertEqual(['invalid name for SRV record'], ctx.exception.reasons) - - # missing priority - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '_srv._tcp', - { - 'type': 'SRV', - 'ttl': 600, - 'value': {'weight': 2, 'port': 3, 'target': 'foo.bar.baz.'}, - }, - ) - self.assertEqual(['missing priority'], ctx.exception.reasons) - - # invalid priority - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '_srv._tcp', - { - 'type': 'SRV', - 'ttl': 600, - 'value': { - 'priority': 'foo', - 'weight': 2, - 'port': 3, - 'target': 'foo.bar.baz.', - }, - }, - ) - self.assertEqual(['invalid priority "foo"'], ctx.exception.reasons) - - # missing weight - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '_srv._tcp', - { - 'type': 'SRV', - 'ttl': 600, - 'value': { - 'priority': 1, - 'port': 3, - 'target': 'foo.bar.baz.', - }, - }, - ) - self.assertEqual(['missing weight'], ctx.exception.reasons) - # invalid weight - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '_srv._tcp', - { - 'type': 'SRV', - 'ttl': 600, - 'value': { - 'priority': 1, - 'weight': 'foo', - 'port': 3, - 'target': 'foo.bar.baz.', - }, - }, - ) - self.assertEqual(['invalid weight "foo"'], ctx.exception.reasons) - - # missing port - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '_srv._tcp', - { - 'type': 'SRV', - 'ttl': 600, - 'value': { - 'priority': 1, - 'weight': 2, - 'target': 'foo.bar.baz.', - }, - }, - ) - self.assertEqual(['missing port'], ctx.exception.reasons) - # invalid port - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '_srv._tcp', - { - 'type': 'SRV', - 'ttl': 600, - 'value': { - 'priority': 1, - 'weight': 2, - 'port': 'foo', - 'target': 'foo.bar.baz.', - }, - }, - ) - self.assertEqual(['invalid port "foo"'], ctx.exception.reasons) - - # missing target - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '_srv._tcp', - { - 'type': 'SRV', - 'ttl': 600, - 'value': {'priority': 1, 'weight': 2, 'port': 3}, - }, - ) - self.assertEqual(['missing target'], ctx.exception.reasons) - # invalid target - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '_srv._tcp', - { - 'type': 'SRV', - 'ttl': 600, - 'value': { - 'priority': 1, - 'weight': 2, - 'port': 3, - 'target': 'foo.bar.baz', - }, - }, - ) - self.assertEqual( - ['SRV value "foo.bar.baz" missing trailing .'], - ctx.exception.reasons, - ) - - # falsey target - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '_srv._tcp', - { - 'type': 'SRV', - 'ttl': 600, - 'value': { - 'priority': 1, - 'weight': 2, - 'port': 3, - 'target': '', - }, - }, - ) - self.assertEqual(['missing target'], ctx.exception.reasons) - - # target must be a valid FQDN - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '_srv._tcp', - { - 'type': 'SRV', - 'ttl': 600, - 'value': { - 'priority': 1, - 'weight': 2, - 'port': 3, - 'target': '100 foo.bar.com.', - }, - }, - ) - self.assertEqual( - ['Invalid SRV target "100 foo.bar.com." is not a valid FQDN.'], - ctx.exception.reasons, - ) - - def test_TLSA(self): - # doesn't blow up - Record.new( - self.zone, - '', - { - 'type': 'TLSA', - 'ttl': 600, - 'value': { - 'certificate_usage': 0, - 'selector': 0, - 'matching_type': 0, - 'certificate_association_data': 'AAAAAAAAAAAAA', - }, - }, - ) - # Multi value, second missing certificate usage - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'TLSA', - 'ttl': 600, - 'values': [ - { - 'certificate_usage': 0, - 'selector': 0, - 'matching_type': 0, - 'certificate_association_data': 'AAAAAAAAAAAAA', - }, - { - 'selector': 0, - 'matching_type': 0, - 'certificate_association_data': 'AAAAAAAAAAAAA', - }, - ], - }, - ) - self.assertEqual( - ['missing certificate_usage'], ctx.exception.reasons - ) - - # missing certificate_association_data - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'TLSA', - 'ttl': 600, - 'value': { - 'certificate_usage': 0, - 'selector': 0, - 'matching_type': 0, - }, - }, - ) - self.assertEqual( - ['missing certificate_association_data'], ctx.exception.reasons - ) - - # missing certificate_usage - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'TLSA', - 'ttl': 600, - 'value': { - 'selector': 0, - 'matching_type': 0, - 'certificate_association_data': 'AAAAAAAAAAAAA', - }, - }, - ) - self.assertEqual( - ['missing certificate_usage'], ctx.exception.reasons - ) - - # False certificate_usage - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'TLSA', - 'ttl': 600, - 'value': { - 'certificate_usage': 4, - 'selector': 0, - 'matching_type': 0, - 'certificate_association_data': 'AAAAAAAAAAAAA', - }, - }, - ) - self.assertEqual( - 'invalid certificate_usage "{value["certificate_usage"]}"', - ctx.exception.reasons, - ) - - # Invalid certificate_usage - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'TLSA', - 'ttl': 600, - 'value': { - 'certificate_usage': 'XYZ', - 'selector': 0, - 'matching_type': 0, - 'certificate_association_data': 'AAAAAAAAAAAAA', - }, - }, - ) - self.assertEqual( - 'invalid certificate_usage "{value["certificate_usage"]}"', - ctx.exception.reasons, - ) - - # missing selector - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'TLSA', - 'ttl': 600, - 'value': { - 'certificate_usage': 0, - 'matching_type': 0, - 'certificate_association_data': 'AAAAAAAAAAAAA', - }, - }, - ) - self.assertEqual(['missing selector'], ctx.exception.reasons) - - # False selector - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'TLSA', - 'ttl': 600, - 'value': { - 'certificate_usage': 0, - 'selector': 4, - 'matching_type': 0, - 'certificate_association_data': 'AAAAAAAAAAAAA', - }, - }, - ) - self.assertEqual( - 'invalid selector "{value["selector"]}"', ctx.exception.reasons - ) - - # Invalid selector - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'TLSA', - 'ttl': 600, - 'value': { - 'certificate_usage': 0, - 'selector': 'XYZ', - 'matching_type': 0, - 'certificate_association_data': 'AAAAAAAAAAAAA', - }, - }, - ) - self.assertEqual( - 'invalid selector "{value["selector"]}"', ctx.exception.reasons - ) - - # missing matching_type - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'TLSA', - 'ttl': 600, - 'value': { - 'certificate_usage': 0, - 'selector': 0, - 'certificate_association_data': 'AAAAAAAAAAAAA', - }, - }, - ) - self.assertEqual(['missing matching_type'], ctx.exception.reasons) - - # False matching_type - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'TLSA', - 'ttl': 600, - 'value': { - 'certificate_usage': 0, - 'selector': 1, - 'matching_type': 3, - 'certificate_association_data': 'AAAAAAAAAAAAA', - }, - }, - ) - self.assertEqual( - 'invalid matching_type "{value["matching_type"]}"', - ctx.exception.reasons, - ) - - # Invalid matching_type - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'TLSA', - 'ttl': 600, - 'value': { - 'certificate_usage': 0, - 'selector': 1, - 'matching_type': 'XYZ', - 'certificate_association_data': 'AAAAAAAAAAAAA', - }, - }, - ) - self.assertEqual( - 'invalid matching_type "{value["matching_type"]}"', - ctx.exception.reasons, - ) - - def test_TXT(self): - # doesn't blow up (name & zone here don't make any sense, but not - # important) - Record.new( - self.zone, - '', - { - 'type': 'TXT', - 'ttl': 600, - 'values': [ - 'hello world', - 'this has some\\; semi-colons\\; in it', - ], - }, - ) - - # missing value - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, '', {'type': 'TXT', 'ttl': 600}) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) - - # missing escapes - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'TXT', - 'ttl': 600, - 'value': 'this has some; semi-colons\\; in it', - }, - ) - self.assertEqual( - ['unescaped ; in "this has some; semi-colons\\; in it"'], - ctx.exception.reasons, - ) - - def test_TXT_long_value_chunking(self): - expected = ( - '"Lorem ipsum dolor sit amet, consectetur adipiscing ' - 'elit, sed do eiusmod tempor incididunt ut labore et dolore ' - 'magna aliqua. Ut enim ad minim veniam, quis nostrud ' - 'exercitation ullamco laboris nisi ut aliquip ex ea commodo ' - 'consequat. Duis aute irure dolor i" "n reprehenderit in ' - 'voluptate velit esse cillum dolore eu fugiat nulla pariatur. ' - 'Excepteur sint occaecat cupidatat non proident, sunt in culpa ' - 'qui officia deserunt mollit anim id est laborum."' - ) - - long_value = ( - 'Lorem ipsum dolor sit amet, consectetur adipiscing ' - 'elit, sed do eiusmod tempor incididunt ut labore et dolore ' - 'magna aliqua. Ut enim ad minim veniam, quis nostrud ' - 'exercitation ullamco laboris nisi ut aliquip ex ea commodo ' - 'consequat. Duis aute irure dolor in reprehenderit in ' - 'voluptate velit esse cillum dolore eu fugiat nulla ' - 'pariatur. Excepteur sint occaecat cupidatat non proident, ' - 'sunt in culpa qui officia deserunt mollit anim id est ' - 'laborum.' - ) - # Single string - single = Record.new( - self.zone, - '', - { - 'type': 'TXT', - 'ttl': 600, - 'values': [ - 'hello world', - long_value, - 'this has some\\; semi-colons\\; in it', - ], - }, - ) - self.assertEqual(3, len(single.values)) - self.assertEqual(3, len(single.chunked_values)) - # Note we are checking that this normalizes the chunking, not that we - # get out what we put in. - self.assertEqual(expected, single.chunked_values[0]) - - long_split_value = ( - '"Lorem ipsum dolor sit amet, consectetur ' - 'adipiscing elit, sed do eiusmod tempor incididunt ut ' - 'labore et dolore magna aliqua. Ut enim ad minim veniam, ' - 'quis nostrud exercitation ullamco laboris nisi ut aliquip ' - 'ex" " ea commodo consequat. Duis aute irure dolor in ' - 'reprehenderit in voluptate velit esse cillum dolore eu ' - 'fugiat nulla pariatur. Excepteur sint occaecat cupidatat ' - 'non proident, sunt in culpa qui officia deserunt mollit ' - 'anim id est laborum."' - ) - # Chunked - chunked = Record.new( - self.zone, - '', - { - 'type': 'TXT', - 'ttl': 600, - 'values': [ - '"hello world"', - long_split_value, - '"this has some\\; semi-colons\\; in it"', - ], - }, - ) - self.assertEqual(expected, chunked.chunked_values[0]) - # should be single values, no quoting - self.assertEqual(single.values, chunked.values) - # should be chunked values, with quoting - self.assertEqual(single.chunked_values, chunked.chunked_values) - - def test_URLFWD(self): - # doesn't blow up - Record.new( - self.zone, - '', - { - 'type': 'URLFWD', - 'ttl': 600, - 'value': { - 'path': '/', - 'target': 'http://foo', - 'code': 301, - 'masking': 2, - 'query': 0, - }, - }, - ) - Record.new( - self.zone, - '', - { - 'type': 'URLFWD', - 'ttl': 600, - 'values': [ - { - 'path': '/', - 'target': 'http://foo', - 'code': 301, - 'masking': 2, - 'query': 0, - }, - { - 'path': '/target', - 'target': 'http://target', - 'code': 302, - 'masking': 2, - 'query': 0, - }, - ], - }, - ) - - # missing path - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'URLFWD', - 'ttl': 600, - 'value': { - 'target': 'http://foo', - 'code': 301, - 'masking': 2, - 'query': 0, - }, - }, - ) - self.assertEqual(['missing path'], ctx.exception.reasons) - - # missing target - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'URLFWD', - 'ttl': 600, - 'value': { - 'path': '/', - 'code': 301, - 'masking': 2, - 'query': 0, - }, - }, - ) - self.assertEqual(['missing target'], ctx.exception.reasons) - - # missing code - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'URLFWD', - 'ttl': 600, - 'value': { - 'path': '/', - 'target': 'http://foo', - 'masking': 2, - 'query': 0, - }, - }, - ) - self.assertEqual(['missing code'], ctx.exception.reasons) - - # invalid code - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'URLFWD', - 'ttl': 600, - 'value': { - 'path': '/', - 'target': 'http://foo', - 'code': 'nope', - 'masking': 2, - 'query': 0, - }, - }, - ) - self.assertEqual(['invalid return code "nope"'], ctx.exception.reasons) - - # unrecognized code - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'URLFWD', - 'ttl': 600, - 'value': { - 'path': '/', - 'target': 'http://foo', - 'code': 3, - 'masking': 2, - 'query': 0, - }, - }, - ) - self.assertEqual( - ['unrecognized return code "3"'], ctx.exception.reasons - ) - - # missing masking - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'URLFWD', - 'ttl': 600, - 'value': { - 'path': '/', - 'target': 'http://foo', - 'code': 301, - 'query': 0, - }, - }, - ) - self.assertEqual(['missing masking'], ctx.exception.reasons) - - # invalid masking - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'URLFWD', - 'ttl': 600, - 'value': { - 'path': '/', - 'target': 'http://foo', - 'code': 301, - 'masking': 'nope', - 'query': 0, - }, - }, - ) - self.assertEqual( - ['invalid masking setting "nope"'], ctx.exception.reasons - ) - - # unrecognized masking - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'URLFWD', - 'ttl': 600, - 'value': { - 'path': '/', - 'target': 'http://foo', - 'code': 301, - 'masking': 3, - 'query': 0, - }, - }, - ) - self.assertEqual( - ['unrecognized masking setting "3"'], ctx.exception.reasons - ) - - # missing query - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'URLFWD', - 'ttl': 600, - 'value': { - 'path': '/', - 'target': 'http://foo', - 'code': 301, - 'masking': 2, - }, - }, - ) - self.assertEqual(['missing query'], ctx.exception.reasons) - - # invalid query - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'URLFWD', - 'ttl': 600, - 'value': { - 'path': '/', - 'target': 'http://foo', - 'code': 301, - 'masking': 2, - 'query': 'nope', - }, - }, - ) - self.assertEqual( - ['invalid query setting "nope"'], ctx.exception.reasons - ) - - # unrecognized query - with self.assertRaises(ValidationError) as ctx: - Record.new( - self.zone, - '', - { - 'type': 'URLFWD', - 'ttl': 600, - 'value': { - 'path': '/', - 'target': 'http://foo', - 'code': 301, - 'masking': 2, - 'query': 3, - }, - }, - ) - self.assertEqual( - ['unrecognized query setting "3"'], ctx.exception.reasons - ) - - -class TestDynamicRecords(TestCase): - zone = Zone('unit.tests.', []) - - def test_simple_a_weighted(self): - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'weight': 10, 'value': '3.3.3.3'}]}, - 'two': { - # Testing out of order value sorting here - 'values': [{'value': '5.5.5.5'}, {'value': '4.4.4.4'}] - }, - 'three': { - 'values': [ - {'weight': 10, 'value': '4.4.4.4'}, - {'weight': 12, 'value': '5.5.5.5'}, - ] - }, - }, - 'rules': [ - {'geos': ['AF', 'EU'], 'pool': 'three'}, - {'geos': ['NA-US-CA'], 'pool': 'two'}, - {'pool': 'one'}, - ], - }, - 'ttl': 60, - 'values': ['1.1.1.1', '2.2.2.2'], - } - a = ARecord(self.zone, 'weighted', a_data) - self.assertEqual('A', a._type) - self.assertEqual(a_data['ttl'], a.ttl) - self.assertEqual(a_data['values'], a.values) - - dynamic = a.dynamic - self.assertTrue(dynamic) - - pools = dynamic.pools - self.assertTrue(pools) - self.assertEqual( - {'value': '3.3.3.3', 'weight': 1, 'status': 'obey'}, - pools['one'].data['values'][0], - ) - self.assertEqual( - [ - {'value': '4.4.4.4', 'weight': 1, 'status': 'obey'}, - {'value': '5.5.5.5', 'weight': 1, 'status': 'obey'}, - ], - pools['two'].data['values'], - ) - self.assertEqual( - [ - {'weight': 10, 'value': '4.4.4.4', 'status': 'obey'}, - {'weight': 12, 'value': '5.5.5.5', 'status': 'obey'}, - ], - pools['three'].data['values'], - ) - - rules = dynamic.rules - self.assertTrue(rules) - self.assertEqual(a_data['dynamic']['rules'][0], rules[0].data) - - # smoke test of _DynamicMixin.__repr__ - a.__repr__() - delattr(a, 'values') - a.value = 'abc' - a.__repr__() - - def test_simple_aaaa_weighted(self): - aaaa_data = { - 'dynamic': { - 'pools': { - 'one': '2601:642:500:e210:62f8:1dff:feb8:9473', - 'two': [ - '2601:642:500:e210:62f8:1dff:feb8:9474', - '2601:642:500:e210:62f8:1dff:feb8:9475', - ], - 'three': { - 1: '2601:642:500:e210:62f8:1dff:feb8:9476', - 2: '2601:642:500:e210:62f8:1dff:feb8:9477', - }, - }, - 'rules': [{'pools': ['three', 'two', 'one']}], - }, - 'ttl': 60, - 'values': [ - '2601:642:500:e210:62f8:1dff:feb8:9471', - '2601:642:500:e210:62f8:1dff:feb8:9472', - ], - } - aaaa_data = { - 'dynamic': { - 'pools': { - 'one': { - 'values': [ - {'value': '2601:642:500:e210:62f8:1dff:feb8:9473'} - ] - }, - 'two': { - # Testing out of order value sorting here - 'values': [ - {'value': '2601:642:500:e210:62f8:1dff:feb8:9475'}, - {'value': '2601:642:500:e210:62f8:1dff:feb8:9474'}, - ] - }, - 'three': { - 'values': [ - { - 'weight': 10, - 'value': '2601:642:500:e210:62f8:1dff:feb8:9476', - }, - { - 'weight': 12, - 'value': '2601:642:500:e210:62f8:1dff:feb8:9477', - }, - ] - }, - }, - 'rules': [ - {'geos': ['AF', 'EU'], 'pool': 'three'}, - {'geos': ['NA-US-CA'], 'pool': 'two'}, - {'pool': 'one'}, - ], - }, - 'ttl': 60, - 'values': [ - '2601:642:500:e210:62f8:1dff:feb8:9471', - '2601:642:500:e210:62f8:1dff:feb8:9472', - ], - } - aaaa = AaaaRecord(self.zone, 'weighted', aaaa_data) - self.assertEqual('AAAA', aaaa._type) - self.assertEqual(aaaa_data['ttl'], aaaa.ttl) - self.assertEqual(aaaa_data['values'], aaaa.values) - - dynamic = aaaa.dynamic - self.assertTrue(dynamic) - - pools = dynamic.pools - self.assertTrue(pools) - self.assertEqual( - { - 'value': '2601:642:500:e210:62f8:1dff:feb8:9473', - 'weight': 1, - 'status': 'obey', - }, - pools['one'].data['values'][0], - ) - self.assertEqual( - [ - { - 'value': '2601:642:500:e210:62f8:1dff:feb8:9474', - 'weight': 1, - 'status': 'obey', - }, - { - 'value': '2601:642:500:e210:62f8:1dff:feb8:9475', - 'weight': 1, - 'status': 'obey', - }, - ], - pools['two'].data['values'], - ) - self.assertEqual( - [ - { - 'weight': 10, - 'value': '2601:642:500:e210:62f8:1dff:feb8:9476', - 'status': 'obey', - }, - { - 'weight': 12, - 'value': '2601:642:500:e210:62f8:1dff:feb8:9477', - 'status': 'obey', - }, - ], - pools['three'].data['values'], - ) - - rules = dynamic.rules - self.assertTrue(rules) - self.assertEqual(aaaa_data['dynamic']['rules'][0], rules[0].data) - - def test_simple_cname_weighted(self): - cname_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': 'one.cname.target.'}]}, - 'two': {'values': [{'value': 'two.cname.target.'}]}, - 'three': { - 'values': [ - {'weight': 12, 'value': 'three-1.cname.target.'}, - {'weight': 32, 'value': 'three-2.cname.target.'}, - ] - }, - }, - 'rules': [ - {'geos': ['AF', 'EU'], 'pool': 'three'}, - {'geos': ['NA-US-CA'], 'pool': 'two'}, - {'pool': 'one'}, - ], - }, - 'ttl': 60, - 'value': 'cname.target.', - } - cname = CnameRecord(self.zone, 'weighted', cname_data) - self.assertEqual('CNAME', cname._type) - self.assertEqual(cname_data['ttl'], cname.ttl) - self.assertEqual(cname_data['value'], cname.value) - - dynamic = cname.dynamic - self.assertTrue(dynamic) - - pools = dynamic.pools - self.assertTrue(pools) - self.assertEqual( - {'value': 'one.cname.target.', 'weight': 1, 'status': 'obey'}, - pools['one'].data['values'][0], - ) - self.assertEqual( - {'value': 'two.cname.target.', 'weight': 1, 'status': 'obey'}, - pools['two'].data['values'][0], - ) - self.assertEqual( - [ - { - 'value': 'three-1.cname.target.', - 'weight': 12, - 'status': 'obey', - }, - { - 'value': 'three-2.cname.target.', - 'weight': 32, - 'status': 'obey', - }, - ], - pools['three'].data['values'], - ) - - rules = dynamic.rules - self.assertTrue(rules) - self.assertEqual(cname_data['dynamic']['rules'][0], rules[0].data) - - def test_dynamic_validation(self): - # Missing pools - a_data = { - 'dynamic': {'rules': [{'pool': 'one'}]}, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ['missing pools', 'rule 1 undefined pool "one"'], - ctx.exception.reasons, - ) - - # Empty pools - a_data = { - 'dynamic': {'pools': {}, 'rules': [{'pool': 'one'}]}, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ['missing pools', 'rule 1 undefined pool "one"'], - ctx.exception.reasons, - ) - - # pools not a dict - a_data = { - 'dynamic': {'pools': [], 'rules': [{'pool': 'one'}]}, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ['pools must be a dict', 'rule 1 undefined pool "one"'], - ctx.exception.reasons, - ) - - # Invalid addresses - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': 'this-aint-right'}]}, - 'two': { - 'fallback': 'one', - 'values': [ - {'value': '4.4.4.4'}, - {'value': 'nor-is-this'}, - ], - }, - 'three': { - 'fallback': 'two', - 'values': [ - {'weight': 1, 'value': '5.5.5.5'}, - {'weight': 2, 'value': 'yet-another-bad-one'}, - ], - }, - }, - 'rules': [ - {'geos': ['AF', 'EU'], 'pool': 'three'}, - {'geos': ['NA-US-CA'], 'pool': 'two'}, - {'pool': 'one'}, - ], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - [ - 'invalid IPv4 address "this-aint-right"', - 'invalid IPv4 address "yet-another-bad-one"', - 'invalid IPv4 address "nor-is-this"', - ], - ctx.exception.reasons, - ) - - # missing value(s) - a_data = { - 'dynamic': { - 'pools': { - 'one': {}, - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - 'three': { - 'values': [ - {'weight': 1, 'value': '6.6.6.6'}, - {'weight': 2, 'value': '7.7.7.7'}, - ] - }, - }, - 'rules': [ - {'geos': ['AF', 'EU'], 'pool': 'three'}, - {'geos': ['NA-US-CA'], 'pool': 'two'}, - {'pool': 'one'}, - ], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ['pool "one" is missing values'], ctx.exception.reasons - ) - - # pool value not a dict - a_data = { - 'dynamic': { - 'pools': { - 'one': '', - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - 'three': { - 'values': [ - {'weight': 1, 'value': '6.6.6.6'}, - {'weight': 2, 'value': '7.7.7.7'}, - ] - }, - }, - 'rules': [ - {'geos': ['AF', 'EU'], 'pool': 'three'}, - {'geos': ['NA-US-CA'], 'pool': 'two'}, - {'pool': 'one'}, - ], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual(['pool "one" must be a dict'], ctx.exception.reasons) - - # empty pool value - a_data = { - 'dynamic': { - 'pools': { - 'one': {}, - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - 'three': { - 'values': [ - {'weight': 1, 'value': '6.6.6.6'}, - {'weight': 2, 'value': '7.7.7.7'}, - ] - }, - }, - 'rules': [ - {'geos': ['AF', 'EU'], 'pool': 'three'}, - {'geos': ['NA-US-CA'], 'pool': 'two'}, - {'pool': 'one'}, - ], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ['pool "one" is missing values'], ctx.exception.reasons - ) - - # invalid int weight - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - 'three': { - 'values': [ - {'weight': 1, 'value': '6.6.6.6'}, - {'weight': 101, 'value': '7.7.7.7'}, - ] - }, - }, - 'rules': [ - {'geos': ['AF', 'EU'], 'pool': 'three'}, - {'geos': ['NA-US-CA'], 'pool': 'two'}, - {'pool': 'one'}, - ], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ['invalid weight "101" in pool "three" value 2'], - ctx.exception.reasons, - ) - - # invalid non-int weight - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - 'three': { - 'values': [ - {'weight': 1, 'value': '6.6.6.6'}, - {'weight': 'foo', 'value': '7.7.7.7'}, - ] - }, - }, - 'rules': [ - {'geos': ['AF', 'EU'], 'pool': 'three'}, - {'geos': ['NA-US-CA'], 'pool': 'two'}, - {'pool': 'one'}, - ], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ['invalid weight "foo" in pool "three" value 2'], - ctx.exception.reasons, - ) - - # single value with weight!=1 - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'weight': 12, 'value': '6.6.6.6'}]} - }, - 'rules': [{'pool': 'one'}], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ['pool "one" has single value with weight!=1'], - ctx.exception.reasons, - ) - - # invalid fallback - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'fallback': 'invalid', - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}], - }, - 'three': { - 'fallback': 'two', - 'values': [ - {'weight': 1, 'value': '6.6.6.6'}, - {'weight': 5, 'value': '7.7.7.7'}, - ], - }, - }, - 'rules': [ - {'geos': ['AF', 'EU'], 'pool': 'three'}, - {'geos': ['NA-US-CA'], 'pool': 'two'}, - {'pool': 'one'}, - ], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ['undefined fallback "invalid" for pool "two"'], - ctx.exception.reasons, - ) - - # fallback loop - a_data = { - 'dynamic': { - 'pools': { - 'one': { - 'fallback': 'three', - 'values': [{'value': '3.3.3.3'}], - }, - 'two': { - 'fallback': 'one', - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}], - }, - 'three': { - 'fallback': 'two', - 'values': [ - {'weight': 1, 'value': '6.6.6.6'}, - {'weight': 5, 'value': '7.7.7.7'}, - ], - }, - }, - 'rules': [ - {'geos': ['AF', 'EU'], 'pool': 'three'}, - {'geos': ['NA-US-CA'], 'pool': 'two'}, - {'pool': 'one'}, - ], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - [ - 'loop in pool fallbacks: one -> three -> two', - 'loop in pool fallbacks: three -> two -> one', - 'loop in pool fallbacks: two -> one -> three', - ], - ctx.exception.reasons, - ) - - # multiple pool problems - a_data = { - 'dynamic': { - 'pools': { - 'one': '', - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': 'blip'}] - }, - 'three': { - 'values': [ - {'weight': 1}, - {'weight': 5000, 'value': '7.7.7.7'}, - ] - }, - }, - 'rules': [ - {'geos': ['AF', 'EU'], 'pool': 'three'}, - {'geos': ['NA-US-CA'], 'pool': 'two'}, - {'pool': 'one'}, - ], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - [ - 'pool "one" must be a dict', - 'missing value in pool "three" value 1', - 'invalid weight "5000" in pool "three" value 2', - 'invalid IPv4 address "blip"', - ], - ctx.exception.reasons, - ) - - # missing rules, and unused pools - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - } - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ['missing rules', 'unused pools: "one", "two"'], - ctx.exception.reasons, - ) - - # empty rules - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - }, - 'rules': [], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ['missing rules', 'unused pools: "one", "two"'], - ctx.exception.reasons, - ) - - # rules not a list/tuple - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - }, - 'rules': {}, - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ['rules must be a list', 'unused pools: "one", "two"'], - ctx.exception.reasons, - ) - - # rule without pool - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - }, - 'rules': [{'geos': ['NA-US-CA']}, {'pool': 'one'}], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ['rule 1 missing pool', 'unused pools: "two"'], - ctx.exception.reasons, - ) - - # rule with non-string pools - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - }, - 'rules': [{'geos': ['NA-US-CA'], 'pool': []}, {'pool': 'one'}], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ['rule 1 invalid pool "[]"', 'unused pools: "two"'], - ctx.exception.reasons, - ) - - # rule references non-existent pool - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - }, - 'rules': [ - {'geos': ['NA-US-CA'], 'pool': 'non-existent'}, - {'pool': 'one'}, - ], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ["rule 1 undefined pool \"non-existent\"", 'unused pools: "two"'], - ctx.exception.reasons, - ) - - # rule with invalid geos - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - }, - 'rules': [{'geos': 'NA-US-CA', 'pool': 'two'}, {'pool': 'one'}], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual(['rule 1 geos must be a list'], ctx.exception.reasons) - - # rule with invalid geo - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - }, - 'rules': [ - {'geos': ['invalid'], 'pool': 'two'}, - {'pool': 'one'}, - ], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ['rule 1 unknown continent code "invalid"'], ctx.exception.reasons - ) - - # multiple default rules - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - }, - 'rules': [{'pool': 'two'}, {'pool': 'one'}], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual(['rule 2 duplicate default'], ctx.exception.reasons) - - # repeated pool in rules - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - }, - 'rules': [ - {'geos': ['EU'], 'pool': 'two'}, - {'geos': ['AF'], 'pool': 'one'}, - {'geos': ['OC'], 'pool': 'one'}, - ], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ['rule 3 invalid, target pool "one" reused'], ctx.exception.reasons - ) - - # Repeated pool is OK if later one is a default - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - }, - 'rules': [ - {'geos': ['EU-GB'], 'pool': 'one'}, - {'geos': ['EU'], 'pool': 'two'}, - {'pool': 'one'}, - ], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - # This should be valid, no exception - Record.new(self.zone, 'bad', a_data) - - # invalid status - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '2.2.2.2', 'status': 'none'}]} - }, - 'rules': [{'pool': 'one'}], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertIn('invalid status', ctx.exception.reasons[0]) - - def test_dynamic_lenient(self): - # Missing pools - a_data = { - 'dynamic': { - 'rules': [{'geos': ['EU'], 'pool': 'two'}, {'pool': 'one'}] - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - a = Record.new(self.zone, 'bad', a_data, lenient=True) - self.assertEqual( - {'pools': {}, 'rules': a_data['dynamic']['rules']}, - a._data()['dynamic'], - ) - - # Missing rule - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [ - {'value': '4.4.4.4'}, - {'value': '5.5.5.5', 'weight': 2}, - ] - }, - } - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - a = Record.new(self.zone, 'bad', a_data, lenient=True) - self.assertEqual( - { - 'pools': { - 'one': { - 'fallback': None, - 'values': [ - {'value': '3.3.3.3', 'weight': 1, 'status': 'obey'} - ], - }, - 'two': { - 'fallback': None, - 'values': [ - {'value': '4.4.4.4', 'weight': 1, 'status': 'obey'}, - {'value': '5.5.5.5', 'weight': 2, 'status': 'obey'}, - ], - }, - }, - 'rules': [], - }, - a._data()['dynamic'], - ) - - # rule without pool - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [ - {'value': '4.4.4.4'}, - {'value': '5.5.5.5', 'weight': 2}, - ] - }, - }, - 'rules': [{'geos': ['EU'], 'pool': 'two'}, {}], - }, - 'ttl': 60, - 'type': 'A', - 'values': ['1.1.1.1', '2.2.2.2'], - } - a = Record.new(self.zone, 'bad', a_data, lenient=True) - self.assertEqual( - { - 'pools': { - 'one': { - 'fallback': None, - 'values': [ - {'value': '3.3.3.3', 'weight': 1, 'status': 'obey'} - ], - }, - 'two': { - 'fallback': None, - 'values': [ - {'value': '4.4.4.4', 'weight': 1, 'status': 'obey'}, - {'value': '5.5.5.5', 'weight': 2, 'status': 'obey'}, - ], - }, - }, - 'rules': a_data['dynamic']['rules'], - }, - a._data()['dynamic'], - ) - - def test_dynamic_changes(self): - simple = SimpleProvider() - dynamic = DynamicProvider() - - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - }, - 'rules': [{'geos': ['EU'], 'pool': 'two'}, {'pool': 'one'}], - }, - 'ttl': 60, - 'values': ['1.1.1.1', '2.2.2.2'], - } - a = ARecord(self.zone, 'weighted', a_data) - dup = ARecord(self.zone, 'weighted', a_data) - - b_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [ - {'value': '4.4.4.4', 'weight': 2}, - {'value': '5.5.5.5'}, - ] - }, - }, - 'rules': [{'geos': ['EU'], 'pool': 'two'}, {'pool': 'one'}], - }, - 'ttl': 60, - 'values': ['1.1.1.1', '2.2.2.2'], - } - b = ARecord(self.zone, 'weighted', b_data) - - c_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] - }, - }, - 'rules': [{'geos': ['NA'], 'pool': 'two'}, {'pool': 'one'}], - }, - 'ttl': 60, - 'values': ['1.1.1.1', '2.2.2.2'], - } - c = ARecord(self.zone, 'weighted', c_data) - - # a changes a (identical dup) is never true - self.assertFalse(a.changes(dup, simple)) - self.assertFalse(a.changes(dup, dynamic)) - - # a changes b is not true for simple - self.assertFalse(a.changes(b, simple)) - # but is true for dynamic - update = a.changes(b, dynamic) - self.assertEqual(a, update.existing) - self.assertEqual(b, update.new) - # transitive - self.assertFalse(b.changes(a, simple)) - update = b.changes(a, dynamic) - self.assertEqual(a, update.existing) - self.assertEqual(b, update.new) - - # same for a change c - self.assertFalse(a.changes(c, simple)) - self.assertTrue(a.changes(c, dynamic)) - self.assertFalse(c.changes(a, simple)) - self.assertTrue(c.changes(a, dynamic)) - - # smoke test some of the equiality bits - self.assertEqual(a.dynamic.pools, a.dynamic.pools) - self.assertEqual(a.dynamic.pools['one'], a.dynamic.pools['one']) - self.assertNotEqual(a.dynamic.pools['one'], a.dynamic.pools['two']) - self.assertEqual(a.dynamic.rules, a.dynamic.rules) - self.assertEqual(a.dynamic.rules[0], a.dynamic.rules[0]) - self.assertNotEqual(a.dynamic.rules[0], c.dynamic.rules[0]) - - def test_dynamic_and_geo_validation(self): - a_data = { - 'dynamic': { - 'pools': { - 'one': {'values': [{'value': '3.3.3.3'}]}, - 'two': { - 'values': [{'value': '5.5.5.5'}, {'value': '4.4.4.4'}] - }, - 'three': { - 'values': [ - {'weight': 10, 'value': '4.4.4.4'}, - {'weight': 12, 'value': '5.5.5.5'}, - ] - }, - }, - 'rules': [ - {'geos': ['AF', 'EU'], 'pool': 'three'}, - {'geos': ['NA-US-CA'], 'pool': 'two'}, - {'pool': 'one'}, - ], - }, - 'geo': {'NA': ['1.2.3.5'], 'NA-US': ['1.2.3.5', '1.2.3.6']}, - 'type': 'A', - 'ttl': 60, - 'values': ['1.1.1.1', '2.2.2.2'], - } - with self.assertRaises(ValidationError) as ctx: - Record.new(self.zone, 'bad', a_data) - self.assertEqual( - ['"dynamic" record with "geo" content'], ctx.exception.reasons - ) - - def test_dynamic_eqs(self): - pool_one = _DynamicPool( - 'one', {'values': [{'value': '1.2.3.4'}]}, Ipv4Address - ) - pool_two = _DynamicPool( - 'two', {'values': [{'value': '1.2.3.5'}]}, Ipv4Address - ) - self.assertEqual(pool_one, pool_one) - self.assertNotEqual(pool_one, pool_two) - self.assertNotEqual(pool_one, 42) - - pools = {'one': pool_one, 'two': pool_two} - rule_one = _DynamicRule(0, {'pool': 'one'}) - rule_two = _DynamicRule(1, {'pool': 'two'}) - self.assertEqual(rule_one, rule_one) - self.assertNotEqual(rule_one, rule_two) - self.assertNotEqual(rule_one, 42) - rules = [rule_one, rule_two] - - dynamic = _Dynamic(pools, rules) - other = _Dynamic({}, []) - self.assertEqual(dynamic, dynamic) - self.assertNotEqual(dynamic, other) - self.assertNotEqual(dynamic, 42) - - def test_dynamic_cname_idna(self): - a_utf8 = 'natación.mx.' - a_encoded = idna_encode(a_utf8) - b_utf8 = 'гэрбүл.mn.' - b_encoded = idna_encode(b_utf8) - cname_data = { - 'dynamic': { - 'pools': { - 'one': { - # Testing out of order value sorting here - 'values': [ - {'value': 'b.unit.tests.'}, - {'value': 'a.unit.tests.'}, - ] - }, - 'two': { - 'values': [ - # some utf8 values we expect to be idna encoded - {'weight': 10, 'value': a_utf8}, - {'weight': 12, 'value': b_utf8}, - ] - }, - }, - 'rules': [ - {'geos': ['NA-US-CA'], 'pool': 'two'}, - {'pool': 'one'}, - ], - }, - 'type': 'CNAME', - 'ttl': 60, - 'value': a_utf8, - } - cname = Record.new(self.zone, 'cname', cname_data) - self.assertEqual(a_encoded, cname.value) - self.assertEqual( - { - 'fallback': None, - 'values': [ - {'weight': 1, 'value': 'a.unit.tests.', 'status': 'obey'}, - {'weight': 1, 'value': 'b.unit.tests.', 'status': 'obey'}, - ], - }, - cname.dynamic.pools['one'].data, - ) - self.assertEqual( - { - 'fallback': None, - 'values': [ - {'weight': 12, 'value': b_encoded, 'status': 'obey'}, - {'weight': 10, 'value': a_encoded, 'status': 'obey'}, - ], - }, - cname.dynamic.pools['two'].data, - ) - - -class TestChanges(TestCase): - zone = Zone('unit.tests.', []) - record_a_1 = Record.new( - zone, '1', {'type': 'A', 'ttl': 30, 'value': '1.2.3.4'} - ) - record_a_2 = Record.new( - zone, '2', {'type': 'A', 'ttl': 30, 'value': '1.2.3.4'} - ) - record_aaaa_1 = Record.new( - zone, - '1', - { - 'type': 'AAAA', - 'ttl': 30, - 'value': '2601:644:500:e210:62f8:1dff:feb8:947a', - }, - ) - record_aaaa_2 = Record.new( - zone, - '2', - { - 'type': 'AAAA', - 'ttl': 30, - 'value': '2601:644:500:e210:62f8:1dff:feb8:947a', - }, - ) - - def test_sort_same_change_type(self): - # expect things to be ordered by name and type since all the change - # types are the same it doesn't matter - changes = [ - Create(self.record_aaaa_1), - Create(self.record_a_2), - Create(self.record_a_1), - Create(self.record_aaaa_2), - ] - self.assertEqual( - [ - Create(self.record_a_1), - Create(self.record_aaaa_1), - Create(self.record_a_2), - Create(self.record_aaaa_2), - ], - sorted(changes), - ) - - def test_sort_same_different_type(self): - # this time the change type is the deciding factor, deletes come before - # creates, and then updates. Things of the same type, go down the line - # and sort by name, and then type - changes = [ - Delete(self.record_aaaa_1), - Create(self.record_aaaa_1), - Update(self.record_aaaa_1, self.record_aaaa_1), - Update(self.record_a_1, self.record_a_1), - Create(self.record_a_1), - Delete(self.record_a_1), - Delete(self.record_aaaa_2), - Create(self.record_aaaa_2), - Update(self.record_aaaa_2, self.record_aaaa_2), - Update(self.record_a_2, self.record_a_2), - Create(self.record_a_2), - Delete(self.record_a_2), - ] - self.assertEqual( - [ - Delete(self.record_a_1), - Delete(self.record_aaaa_1), - Delete(self.record_a_2), - Delete(self.record_aaaa_2), - Create(self.record_a_1), - Create(self.record_aaaa_1), - Create(self.record_a_2), - Create(self.record_aaaa_2), - Update(self.record_a_1, self.record_a_1), - Update(self.record_aaaa_1, self.record_aaaa_1), - Update(self.record_a_2, self.record_a_2), - Update(self.record_aaaa_2, self.record_aaaa_2), - ], - sorted(changes), - ) diff --git a/tests/test_octodns_record_a.py b/tests/test_octodns_record_a.py new file mode 100644 index 0000000..95d34f7 --- /dev/null +++ b/tests/test_octodns_record_a.py @@ -0,0 +1,181 @@ +# +# +# + +from unittest import TestCase + +from octodns.record import Record +from octodns.record.a import ARecord +from octodns.record.exception import ValidationError +from octodns.zone import Zone + +from helpers import SimpleProvider + + +class TestRecordA(TestCase): + zone = Zone('unit.tests.', []) + + def test_a_and_record(self): + a_values = ['1.2.3.4', '2.2.3.4'] + a_data = {'ttl': 30, 'values': a_values} + a = ARecord(self.zone, 'a', a_data) + self.assertEqual('a', a.name) + self.assertEqual('a.unit.tests.', a.fqdn) + self.assertEqual(30, a.ttl) + self.assertEqual(a_values, a.values) + self.assertEqual(a_data, a.data) + + b_value = '3.2.3.4' + b_data = {'ttl': 30, 'value': b_value} + b = ARecord(self.zone, 'b', b_data) + self.assertEqual([b_value], b.values) + self.assertEqual(b_data, b.data) + + # top-level + data = {'ttl': 30, 'value': '4.2.3.4'} + self.assertEqual(self.zone.name, ARecord(self.zone, '', data).fqdn) + self.assertEqual(self.zone.name, ARecord(self.zone, None, data).fqdn) + + # ARecord equate with itself + self.assertTrue(a == a) + # Records with differing names and same type don't equate + self.assertFalse(a == b) + # Records with same name & type equate even if ttl is different + self.assertTrue( + a == ARecord(self.zone, 'a', {'ttl': 31, 'values': a_values}) + ) + # Records with same name & type equate even if values are different + self.assertTrue( + a == ARecord(self.zone, 'a', {'ttl': 30, 'value': b_value}) + ) + + target = SimpleProvider() + # no changes if self + self.assertFalse(a.changes(a, target)) + # no changes if clone + other = ARecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + self.assertFalse(a.changes(other, target)) + # changes if ttl modified + other.ttl = 31 + update = a.changes(other, target) + self.assertEqual(a, update.existing) + self.assertEqual(other, update.new) + # changes if values modified + other.ttl = a.ttl + other.values = ['4.4.4.4'] + update = a.changes(other, target) + self.assertEqual(a, update.existing) + self.assertEqual(other, update.new) + + # Hashing + records = set() + records.add(a) + self.assertTrue(a in records) + self.assertFalse(b in records) + records.add(b) + self.assertTrue(b in records) + + # __repr__ doesn't blow up + a.__repr__() + # Record.__repr__ does + with self.assertRaises(NotImplementedError): + + class DummyRecord(Record): + def __init__(self): + pass + + DummyRecord().__repr__() + + def test_validation_and_values_mixin(self): + # doesn't blow up + Record.new(self.zone, '', {'type': 'A', 'ttl': 600, 'value': '1.2.3.4'}) + Record.new( + self.zone, '', {'type': 'A', 'ttl': 600, 'values': ['1.2.3.4']} + ) + Record.new( + self.zone, + '', + {'type': 'A', 'ttl': 600, 'values': ['1.2.3.4', '1.2.3.5']}, + ) + + # missing value(s), no value or value + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', {'type': 'A', 'ttl': 600}) + self.assertEqual(['missing value(s)'], ctx.exception.reasons) + + # missing value(s), empty values + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, 'www', {'type': 'A', 'ttl': 600, 'values': []} + ) + self.assertEqual(['missing value(s)'], ctx.exception.reasons) + + # missing value(s), None values + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, 'www', {'type': 'A', 'ttl': 600, 'values': None} + ) + self.assertEqual(['missing value(s)'], ctx.exception.reasons) + + # missing value(s) and empty value + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + 'www', + {'type': 'A', 'ttl': 600, 'values': [None, '']}, + ) + self.assertEqual( + ['missing value(s)', 'empty value'], ctx.exception.reasons + ) + + # missing value(s), None value + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, 'www', {'type': 'A', 'ttl': 600, 'value': None} + ) + self.assertEqual(['missing value(s)'], ctx.exception.reasons) + + # empty value, empty string value + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'www', {'type': 'A', 'ttl': 600, 'value': ''}) + self.assertEqual(['empty value'], ctx.exception.reasons) + + # missing value(s) & ttl + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', {'type': 'A'}) + self.assertEqual( + ['missing ttl', 'missing value(s)'], ctx.exception.reasons + ) + + # invalid ipv4 address + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, '', {'type': 'A', 'ttl': 600, 'value': 'hello'} + ) + self.assertEqual( + ['invalid IPv4 address "hello"'], ctx.exception.reasons + ) + + # invalid ipv4 addresses + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + {'type': 'A', 'ttl': 600, 'values': ['hello', 'goodbye']}, + ) + self.assertEqual( + ['invalid IPv4 address "hello"', 'invalid IPv4 address "goodbye"'], + ctx.exception.reasons, + ) + + # invalid & valid ipv4 addresses, no ttl + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + {'type': 'A', 'values': ['1.2.3.4', 'hello', '5.6.7.8']}, + ) + self.assertEqual( + ['missing ttl', 'invalid IPv4 address "hello"'], + ctx.exception.reasons, + ) diff --git a/tests/test_octodns_record_aaaa.py b/tests/test_octodns_record_aaaa.py new file mode 100644 index 0000000..6efe3a1 --- /dev/null +++ b/tests/test_octodns_record_aaaa.py @@ -0,0 +1,227 @@ +# +# +# + +from unittest import TestCase + +from octodns.record import Record +from octodns.record.aaaa import AaaaRecord +from octodns.record.exception import ValidationError +from octodns.zone import Zone + + +class TestRecordAaaa(TestCase): + zone = Zone('unit.tests.', []) + + def assertMultipleValues(self, _type, a_values, b_value): + a_data = {'ttl': 30, 'values': a_values} + a = _type(self.zone, 'a', a_data) + self.assertEqual('a', a.name) + self.assertEqual('a.unit.tests.', a.fqdn) + self.assertEqual(30, a.ttl) + self.assertEqual(a_values, a.values) + self.assertEqual(a_data, a.data) + + b_data = {'ttl': 30, 'value': b_value} + b = _type(self.zone, 'b', b_data) + self.assertEqual([b_value], b.values) + self.assertEqual(b_data, b.data) + + def test_aaaa(self): + a_values = [ + '2001:db8:3c4d:15::1a2f:1a2b', + '2001:db8:3c4d:15::1a2f:1a3b', + ] + b_value = '2001:db8:3c4d:15::1a2f:1a4b' + self.assertMultipleValues(AaaaRecord, a_values, b_value) + + # Specifically validate that we normalize IPv6 addresses + values = [ + '2001:db8:3c4d:15:0000:0000:1a2f:1a2b', + '2001:0db8:3c4d:0015::1a2f:1a3b', + ] + data = {'ttl': 30, 'values': values} + record = AaaaRecord(self.zone, 'aaaa', data) + self.assertEqual(a_values, record.values) + + def test_validation(self): + # doesn't blow up + Record.new( + self.zone, + '', + { + 'type': 'AAAA', + 'ttl': 600, + 'value': '2601:644:500:e210:62f8:1dff:feb8:947a', + }, + ) + Record.new( + self.zone, + '', + { + 'type': 'AAAA', + 'ttl': 600, + 'values': ['2601:644:500:e210:62f8:1dff:feb8:947a'], + }, + ) + Record.new( + self.zone, + '', + { + 'type': 'AAAA', + 'ttl': 600, + 'values': [ + '2601:644:500:e210:62f8:1dff:feb8:947a', + '2601:642:500:e210:62f8:1dff:feb8:947a', + ], + }, + ) + + # missing value(s), no value or value + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', {'type': 'AAAA', 'ttl': 600}) + self.assertEqual(['missing value(s)'], ctx.exception.reasons) + + # missing value(s), empty values + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, 'www', {'type': 'AAAA', 'ttl': 600, 'values': []} + ) + self.assertEqual(['missing value(s)'], ctx.exception.reasons) + + # missing value(s), None values + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, 'www', {'type': 'AAAA', 'ttl': 600, 'values': None} + ) + self.assertEqual(['missing value(s)'], ctx.exception.reasons) + + # missing value(s) and empty value + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + 'www', + {'type': 'AAAA', 'ttl': 600, 'values': [None, '']}, + ) + self.assertEqual( + ['missing value(s)', 'empty value'], ctx.exception.reasons + ) + + # missing value(s), None value + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, 'www', {'type': 'AAAA', 'ttl': 600, 'value': None} + ) + self.assertEqual(['missing value(s)'], ctx.exception.reasons) + + # empty value, empty string value + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, 'www', {'type': 'AAAA', 'ttl': 600, 'value': ''} + ) + self.assertEqual(['empty value'], ctx.exception.reasons) + + # missing value(s) & ttl + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', {'type': 'AAAA'}) + self.assertEqual( + ['missing ttl', 'missing value(s)'], ctx.exception.reasons + ) + + # invalid IPv6 address + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, '', {'type': 'AAAA', 'ttl': 600, 'value': 'hello'} + ) + self.assertEqual( + ['invalid IPv6 address "hello"'], ctx.exception.reasons + ) + + # invalid IPv6 addresses + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + {'type': 'AAAA', 'ttl': 600, 'values': ['hello', 'goodbye']}, + ) + self.assertEqual( + ['invalid IPv6 address "hello"', 'invalid IPv6 address "goodbye"'], + ctx.exception.reasons, + ) + + # invalid & valid IPv6 addresses, no ttl + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'AAAA', + 'values': [ + '2601:644:500:e210:62f8:1dff:feb8:947a', + 'hello', + '2601:642:500:e210:62f8:1dff:feb8:947a', + ], + }, + ) + self.assertEqual( + ['missing ttl', 'invalid IPv6 address "hello"'], + ctx.exception.reasons, + ) + + def test_more_validation(self): + # doesn't blow up + Record.new( + self.zone, + '', + { + 'type': 'AAAA', + 'ttl': 600, + 'value': '2601:644:500:e210:62f8:1dff:feb8:947a', + }, + ) + Record.new( + self.zone, + '', + { + 'type': 'AAAA', + 'ttl': 600, + 'values': [ + '2601:644:500:e210:62f8:1dff:feb8:947a', + '2601:644:500:e210:62f8:1dff:feb8:947b', + ], + }, + ) + + # invalid ip address + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, '', {'type': 'AAAA', 'ttl': 600, 'value': 'hello'} + ) + self.assertEqual( + ['invalid IPv6 address "hello"'], ctx.exception.reasons + ) + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + {'type': 'AAAA', 'ttl': 600, 'values': ['1.2.3.4', '2.3.4.5']}, + ) + self.assertEqual( + [ + 'invalid IPv6 address "1.2.3.4"', + 'invalid IPv6 address "2.3.4.5"', + ], + ctx.exception.reasons, + ) + + # invalid ip addresses + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + {'type': 'AAAA', 'ttl': 600, 'values': ['hello', 'goodbye']}, + ) + self.assertEqual( + ['invalid IPv6 address "hello"', 'invalid IPv6 address "goodbye"'], + ctx.exception.reasons, + ) diff --git a/tests/test_octodns_record_alias.py b/tests/test_octodns_record_alias.py new file mode 100644 index 0000000..4cfc0aa --- /dev/null +++ b/tests/test_octodns_record_alias.py @@ -0,0 +1,108 @@ +# +# +# + +from unittest import TestCase + +from octodns.record import Record +from octodns.record.alias import AliasRecord +from octodns.record.exception import ValidationError +from octodns.zone import Zone + +from helpers import SimpleProvider + + +class TestRecordAlias(TestCase): + zone = Zone('unit.tests.', []) + + def test_alias(self): + a_data = {'ttl': 0, 'value': 'www.unit.tests.'} + a = AliasRecord(self.zone, '', a_data) + self.assertEqual('', a.name) + self.assertEqual('unit.tests.', a.fqdn) + self.assertEqual(0, a.ttl) + self.assertEqual(a_data['value'], a.value) + self.assertEqual(a_data, a.data) + + target = SimpleProvider() + # No changes with self + self.assertFalse(a.changes(a, target)) + # Diff in value causes change + other = AliasRecord(self.zone, 'a', a_data) + other.value = 'foo.unit.tests.' + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + + # __repr__ doesn't blow up + a.__repr__() + + def test_alias_lowering_value(self): + upper_record = AliasRecord( + self.zone, + 'aliasUppwerValue', + {'ttl': 30, 'type': 'ALIAS', 'value': 'GITHUB.COM'}, + ) + lower_record = AliasRecord( + self.zone, + 'aliasLowerValue', + {'ttl': 30, 'type': 'ALIAS', 'value': 'github.com'}, + ) + self.assertEqual(upper_record.value, lower_record.value) + + def test_validation_and_value_mixin(self): + # doesn't blow up + Record.new( + self.zone, + '', + {'type': 'ALIAS', 'ttl': 600, 'value': 'foo.bar.com.'}, + ) + + # root only + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + 'nope', + {'type': 'ALIAS', 'ttl': 600, 'value': 'foo.bar.com.'}, + ) + self.assertEqual(['non-root ALIAS not allowed'], ctx.exception.reasons) + + # missing value + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', {'type': 'ALIAS', 'ttl': 600}) + self.assertEqual(['missing value'], ctx.exception.reasons) + + # missing value + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, '', {'type': 'ALIAS', 'ttl': 600, 'value': None} + ) + self.assertEqual(['missing value'], ctx.exception.reasons) + + # empty value + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, '', {'type': 'ALIAS', 'ttl': 600, 'value': ''} + ) + self.assertEqual(['empty value'], ctx.exception.reasons) + + # not a valid FQDN + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, '', {'type': 'ALIAS', 'ttl': 600, 'value': '__.'} + ) + self.assertEqual( + ['ALIAS value "__." is not a valid FQDN'], ctx.exception.reasons + ) + + # missing trailing . + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + {'type': 'ALIAS', 'ttl': 600, 'value': 'foo.bar.com'}, + ) + self.assertEqual( + ['ALIAS value "foo.bar.com" missing trailing .'], + ctx.exception.reasons, + ) diff --git a/tests/test_octodns_record_caa.py b/tests/test_octodns_record_caa.py new file mode 100644 index 0000000..123430e --- /dev/null +++ b/tests/test_octodns_record_caa.py @@ -0,0 +1,273 @@ +# +# +# + +from unittest import TestCase + +from octodns.record import Record +from octodns.record.caa import CaaRecord, CaaValue +from octodns.record.exception import ValidationError +from octodns.record.rr import RrParseError +from octodns.zone import Zone + +from helpers import SimpleProvider + + +class TestRecordCaa(TestCase): + zone = Zone('unit.tests.', []) + + def test_caa(self): + a_values = [ + CaaValue({'flags': 0, 'tag': 'issue', 'value': 'ca.example.net'}), + CaaValue( + { + 'flags': 128, + 'tag': 'iodef', + 'value': 'mailto:security@example.com', + } + ), + ] + a_data = {'ttl': 30, 'values': a_values} + a = CaaRecord(self.zone, 'a', a_data) + self.assertEqual('a', a.name) + self.assertEqual('a.unit.tests.', a.fqdn) + self.assertEqual(30, a.ttl) + self.assertEqual(a_values[0]['flags'], a.values[0].flags) + self.assertEqual(a_values[0]['tag'], a.values[0].tag) + self.assertEqual(a_values[0]['value'], a.values[0].value) + self.assertEqual(a_values[1]['flags'], a.values[1].flags) + self.assertEqual(a_values[1]['tag'], a.values[1].tag) + self.assertEqual(a_values[1]['value'], a.values[1].value) + self.assertEqual(a_data, a.data) + + b_value = CaaValue( + {'tag': 'iodef', 'value': 'http://iodef.example.com/'} + ) + b_data = {'ttl': 30, 'value': b_value} + b = CaaRecord(self.zone, 'b', b_data) + self.assertEqual(0, b.values[0].flags) + self.assertEqual(b_value['tag'], b.values[0].tag) + self.assertEqual(b_value['value'], b.values[0].value) + b_data['value']['flags'] = 0 + self.assertEqual(b_data, b.data) + + target = SimpleProvider() + # No changes with self + self.assertFalse(a.changes(a, target)) + # Diff in flags causes change + other = CaaRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].flags = 128 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in tag causes change + other.values[0].flags = a.values[0].flags + other.values[0].tag = 'foo' + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in value causes change + other.values[0].tag = a.values[0].tag + other.values[0].value = 'bar' + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + + # __repr__ doesn't blow up + a.__repr__() + + def test_caa_value_rdata_text(self): + # empty string won't parse + with self.assertRaises(RrParseError): + CaaValue.parse_rdata_text('') + + # single word won't parse + with self.assertRaises(RrParseError): + CaaValue.parse_rdata_text('nope') + + # 2nd word won't parse + with self.assertRaises(RrParseError): + CaaValue.parse_rdata_text('0 tag') + + # 4th word won't parse + with self.assertRaises(RrParseError): + CaaValue.parse_rdata_text('1 tag value another') + + # flags not an int, will parse + self.assertEqual( + {'flags': 'one', 'tag': 'tag', 'value': 'value'}, + CaaValue.parse_rdata_text('one tag value'), + ) + + # valid + self.assertEqual( + {'flags': 0, 'tag': 'tag', 'value': '99148c81'}, + CaaValue.parse_rdata_text('0 tag 99148c81'), + ) + + zone = Zone('unit.tests.', []) + a = CaaRecord( + zone, + 'caa', + { + 'ttl': 32, + 'values': [ + {'flags': 1, 'tag': 'tag1', 'value': '99148c81'}, + {'flags': 2, 'tag': 'tag2', 'value': '99148c44'}, + ], + }, + ) + self.assertEqual(1, a.values[0].flags) + self.assertEqual('tag1', a.values[0].tag) + self.assertEqual('99148c81', a.values[0].value) + self.assertEqual('1 tag1 99148c81', a.values[0].rdata_text) + self.assertEqual(2, a.values[1].flags) + self.assertEqual('tag2', a.values[1].tag) + self.assertEqual('99148c44', a.values[1].value) + self.assertEqual('2 tag2 99148c44', a.values[1].rdata_text) + + def test_caa_value(self): + a = CaaValue({'flags': 0, 'tag': 'a', 'value': 'v'}) + b = CaaValue({'flags': 1, 'tag': 'a', 'value': 'v'}) + c = CaaValue({'flags': 0, 'tag': 'c', 'value': 'v'}) + d = CaaValue({'flags': 0, 'tag': 'a', 'value': 'z'}) + + self.assertEqual(a, a) + self.assertEqual(b, b) + self.assertEqual(c, c) + self.assertEqual(d, d) + + self.assertNotEqual(a, b) + self.assertNotEqual(a, c) + self.assertNotEqual(a, d) + self.assertNotEqual(b, a) + self.assertNotEqual(b, c) + self.assertNotEqual(b, d) + self.assertNotEqual(c, a) + self.assertNotEqual(c, b) + self.assertNotEqual(c, d) + + self.assertTrue(a < b) + self.assertTrue(a < c) + self.assertTrue(a < d) + + self.assertTrue(b > a) + self.assertTrue(b > c) + self.assertTrue(b > d) + + self.assertTrue(c > a) + self.assertTrue(c < b) + self.assertTrue(c > d) + + self.assertTrue(d > a) + self.assertTrue(d < b) + self.assertTrue(d < c) + + self.assertTrue(a <= b) + self.assertTrue(a <= c) + self.assertTrue(a <= d) + self.assertTrue(a <= a) + self.assertTrue(a >= a) + + self.assertTrue(b >= a) + self.assertTrue(b >= c) + self.assertTrue(b >= d) + self.assertTrue(b >= b) + self.assertTrue(b <= b) + + self.assertTrue(c >= a) + self.assertTrue(c <= b) + self.assertTrue(c >= d) + self.assertTrue(c >= c) + self.assertTrue(c <= c) + + self.assertTrue(d >= a) + self.assertTrue(d <= b) + self.assertTrue(d <= c) + self.assertTrue(d >= d) + self.assertTrue(d <= d) + + def test_validation(self): + # doesn't blow up + Record.new( + self.zone, + '', + { + 'type': 'CAA', + 'ttl': 600, + 'value': { + 'flags': 128, + 'tag': 'iodef', + 'value': 'http://foo.bar.com/', + }, + }, + ) + + # invalid flags + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'CAA', + 'ttl': 600, + 'value': { + 'flags': -42, + 'tag': 'iodef', + 'value': 'http://foo.bar.com/', + }, + }, + ) + self.assertEqual(['invalid flags "-42"'], ctx.exception.reasons) + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'CAA', + 'ttl': 600, + 'value': { + 'flags': 442, + 'tag': 'iodef', + 'value': 'http://foo.bar.com/', + }, + }, + ) + self.assertEqual(['invalid flags "442"'], ctx.exception.reasons) + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'CAA', + 'ttl': 600, + 'value': { + 'flags': 'nope', + 'tag': 'iodef', + 'value': 'http://foo.bar.com/', + }, + }, + ) + self.assertEqual(['invalid flags "nope"'], ctx.exception.reasons) + + # missing tag + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'CAA', + 'ttl': 600, + 'value': {'value': 'http://foo.bar.com/'}, + }, + ) + self.assertEqual(['missing tag'], ctx.exception.reasons) + + # missing value + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + {'type': 'CAA', 'ttl': 600, 'value': {'tag': 'iodef'}}, + ) + self.assertEqual(['missing value'], ctx.exception.reasons) diff --git a/tests/test_octodns_record_change.py b/tests/test_octodns_record_change.py new file mode 100644 index 0000000..e897c67 --- /dev/null +++ b/tests/test_octodns_record_change.py @@ -0,0 +1,92 @@ +# +# +# + +from unittest import TestCase + +from octodns.record import Record +from octodns.record.change import Create, Delete, Update +from octodns.zone import Zone + + +class TestChanges(TestCase): + zone = Zone('unit.tests.', []) + record_a_1 = Record.new( + zone, '1', {'type': 'A', 'ttl': 30, 'value': '1.2.3.4'} + ) + record_a_2 = Record.new( + zone, '2', {'type': 'A', 'ttl': 30, 'value': '1.2.3.4'} + ) + record_aaaa_1 = Record.new( + zone, + '1', + { + 'type': 'AAAA', + 'ttl': 30, + 'value': '2601:644:500:e210:62f8:1dff:feb8:947a', + }, + ) + record_aaaa_2 = Record.new( + zone, + '2', + { + 'type': 'AAAA', + 'ttl': 30, + 'value': '2601:644:500:e210:62f8:1dff:feb8:947a', + }, + ) + + def test_sort_same_change_type(self): + # expect things to be ordered by name and type since all the change + # types are the same it doesn't matter + changes = [ + Create(self.record_aaaa_1), + Create(self.record_a_2), + Create(self.record_a_1), + Create(self.record_aaaa_2), + ] + self.assertEqual( + [ + Create(self.record_a_1), + Create(self.record_aaaa_1), + Create(self.record_a_2), + Create(self.record_aaaa_2), + ], + sorted(changes), + ) + + def test_sort_same_different_type(self): + # this time the change type is the deciding factor, deletes come before + # creates, and then updates. Things of the same type, go down the line + # and sort by name, and then type + changes = [ + Delete(self.record_aaaa_1), + Create(self.record_aaaa_1), + Update(self.record_aaaa_1, self.record_aaaa_1), + Update(self.record_a_1, self.record_a_1), + Create(self.record_a_1), + Delete(self.record_a_1), + Delete(self.record_aaaa_2), + Create(self.record_aaaa_2), + Update(self.record_aaaa_2, self.record_aaaa_2), + Update(self.record_a_2, self.record_a_2), + Create(self.record_a_2), + Delete(self.record_a_2), + ] + self.assertEqual( + [ + Delete(self.record_a_1), + Delete(self.record_aaaa_1), + Delete(self.record_a_2), + Delete(self.record_aaaa_2), + Create(self.record_a_1), + Create(self.record_aaaa_1), + Create(self.record_a_2), + Create(self.record_aaaa_2), + Update(self.record_a_1, self.record_a_1), + Update(self.record_aaaa_1, self.record_aaaa_1), + Update(self.record_a_2, self.record_a_2), + Update(self.record_aaaa_2, self.record_aaaa_2), + ], + sorted(changes), + ) diff --git a/tests/test_octodns_record_chunked.py b/tests/test_octodns_record_chunked.py new file mode 100644 index 0000000..2fa8c0d --- /dev/null +++ b/tests/test_octodns_record_chunked.py @@ -0,0 +1,37 @@ +# +# +# + +from unittest import TestCase + +from octodns.record.chunked import _ChunkedValue +from octodns.record.spf import SpfRecord +from octodns.zone import Zone + + +class TestRecordChunked(TestCase): + def test_chunked_value_rdata_text(self): + for s in ( + None, + '', + 'word', + 42, + 42.43, + '1.2.3', + 'some.words.that.here', + '1.2.word.4', + '1.2.3.4', + ): + self.assertEqual(s, _ChunkedValue.parse_rdata_text(s)) + + # semi-colons are escaped + self.assertEqual( + 'Hello\\; World!', _ChunkedValue.parse_rdata_text('Hello; World!') + ) + + # since we're always a string validate and __init__ don't + # parse_rdata_text + + zone = Zone('unit.tests.', []) + a = SpfRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) + self.assertEqual('some.target.', a.values[0].rdata_text) diff --git a/tests/test_octodns_record_cname.py b/tests/test_octodns_record_cname.py new file mode 100644 index 0000000..08a7ca6 --- /dev/null +++ b/tests/test_octodns_record_cname.py @@ -0,0 +1,136 @@ +# +# +# + +from unittest import TestCase + +from octodns.record import Record +from octodns.record.cname import CnameRecord +from octodns.record.exception import ValidationError +from octodns.zone import Zone + +from helpers import SimpleProvider + + +class TestRecordCname(TestCase): + zone = Zone('unit.tests.', []) + + def assertSingleValue(self, _type, a_value, b_value): + a_data = {'ttl': 30, 'value': a_value} + a = _type(self.zone, 'a', a_data) + self.assertEqual('a', a.name) + self.assertEqual('a.unit.tests.', a.fqdn) + self.assertEqual(30, a.ttl) + self.assertEqual(a_value, a.value) + self.assertEqual(a_data, a.data) + + b_data = {'ttl': 30, 'value': b_value} + b = _type(self.zone, 'b', b_data) + self.assertEqual(b_value, b.value) + self.assertEqual(b_data, b.data) + + target = SimpleProvider() + # No changes with self + self.assertFalse(a.changes(a, target)) + # Diff in value causes change + other = _type(self.zone, 'a', {'ttl': 30, 'value': b_value}) + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + + # __repr__ doesn't blow up + a.__repr__() + + def test_cname(self): + self.assertSingleValue(CnameRecord, 'target.foo.com.', 'other.foo.com.') + + def test_cname_lowering_value(self): + upper_record = CnameRecord( + self.zone, + 'CnameUppwerValue', + {'ttl': 30, 'type': 'CNAME', 'value': 'GITHUB.COM'}, + ) + lower_record = CnameRecord( + self.zone, + 'CnameLowerValue', + {'ttl': 30, 'type': 'CNAME', 'value': 'github.com'}, + ) + self.assertEqual(upper_record.value, lower_record.value) + + def test_validation(self): + # doesn't blow up + Record.new( + self.zone, + 'www', + {'type': 'CNAME', 'ttl': 600, 'value': 'foo.bar.com.'}, + ) + + # root cname is a no-no + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + {'type': 'CNAME', 'ttl': 600, 'value': 'foo.bar.com.'}, + ) + self.assertEqual(['root CNAME not allowed'], ctx.exception.reasons) + + # not a valid FQDN + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, 'www', {'type': 'CNAME', 'ttl': 600, 'value': '___.'} + ) + self.assertEqual( + ['CNAME value "___." is not a valid FQDN'], ctx.exception.reasons + ) + + # missing trailing . + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + 'www', + {'type': 'CNAME', 'ttl': 600, 'value': 'foo.bar.com'}, + ) + self.assertEqual( + ['CNAME value "foo.bar.com" missing trailing .'], + ctx.exception.reasons, + ) + + # doesn't allow urls + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + 'www', + {'type': 'CNAME', 'ttl': 600, 'value': 'https://google.com'}, + ) + self.assertEqual( + ['CNAME value "https://google.com" is not a valid FQDN'], + ctx.exception.reasons, + ) + + # doesn't allow urls with paths + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + 'www', + { + 'type': 'CNAME', + 'ttl': 600, + 'value': 'https://google.com/a/b/c', + }, + ) + self.assertEqual( + ['CNAME value "https://google.com/a/b/c" is not a valid FQDN'], + ctx.exception.reasons, + ) + + # doesn't allow paths + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + 'www', + {'type': 'CNAME', 'ttl': 600, 'value': 'google.com/some/path'}, + ) + self.assertEqual( + ['CNAME value "google.com/some/path" is not a valid FQDN'], + ctx.exception.reasons, + ) diff --git a/tests/test_octodns_record_dname.py b/tests/test_octodns_record_dname.py new file mode 100644 index 0000000..8b2d67a --- /dev/null +++ b/tests/test_octodns_record_dname.py @@ -0,0 +1,94 @@ +# +# +# + +from unittest import TestCase + +from octodns.record import Record +from octodns.record.dname import DnameRecord +from octodns.record.exception import ValidationError +from octodns.zone import Zone + +from helpers import SimpleProvider + + +class TestRecordDname(TestCase): + zone = Zone('unit.tests.', []) + + def assertSingleValue(self, _type, a_value, b_value): + a_data = {'ttl': 30, 'value': a_value} + a = _type(self.zone, 'a', a_data) + self.assertEqual('a', a.name) + self.assertEqual('a.unit.tests.', a.fqdn) + self.assertEqual(30, a.ttl) + self.assertEqual(a_value, a.value) + self.assertEqual(a_data, a.data) + + b_data = {'ttl': 30, 'value': b_value} + b = _type(self.zone, 'b', b_data) + self.assertEqual(b_value, b.value) + self.assertEqual(b_data, b.data) + + target = SimpleProvider() + # No changes with self + self.assertFalse(a.changes(a, target)) + # Diff in value causes change + other = _type(self.zone, 'a', {'ttl': 30, 'value': b_value}) + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + + # __repr__ doesn't blow up + a.__repr__() + + def test_dname(self): + self.assertSingleValue(DnameRecord, 'target.foo.com.', 'other.foo.com.') + + def test_dname_lowering_value(self): + upper_record = DnameRecord( + self.zone, + 'DnameUppwerValue', + {'ttl': 30, 'type': 'DNAME', 'value': 'GITHUB.COM'}, + ) + lower_record = DnameRecord( + self.zone, + 'DnameLowerValue', + {'ttl': 30, 'type': 'DNAME', 'value': 'github.com'}, + ) + self.assertEqual(upper_record.value, lower_record.value) + + def test_validation(self): + # A valid DNAME record. + Record.new( + self.zone, + 'sub', + {'type': 'DNAME', 'ttl': 600, 'value': 'foo.bar.com.'}, + ) + + # A DNAME record can be present at the zone APEX. + Record.new( + self.zone, + '', + {'type': 'DNAME', 'ttl': 600, 'value': 'foo.bar.com.'}, + ) + + # not a valid FQDN + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, 'www', {'type': 'DNAME', 'ttl': 600, 'value': '.'} + ) + self.assertEqual( + ['DNAME value "." is not a valid FQDN'], ctx.exception.reasons + ) + + # missing trailing . + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + 'www', + {'type': 'DNAME', 'ttl': 600, 'value': 'foo.bar.com'}, + ) + self.assertEqual( + ['DNAME value "foo.bar.com" missing trailing .'], + ctx.exception.reasons, + ) diff --git a/tests/test_octodns_record_ds.py b/tests/test_octodns_record_ds.py new file mode 100644 index 0000000..0cb7eed --- /dev/null +++ b/tests/test_octodns_record_ds.py @@ -0,0 +1,206 @@ +# +# +# + +from unittest import TestCase + +from octodns.record.ds import DsRecord, DsValue +from octodns.record.rr import RrParseError +from octodns.zone import Zone + + +class TestRecordDs(TestCase): + def test_ds(self): + for a, b in ( + # diff flags + ( + { + 'flags': 0, + 'protocol': 1, + 'algorithm': 2, + 'public_key': 'abcdef0123456', + }, + { + 'flags': 1, + 'protocol': 1, + 'algorithm': 2, + 'public_key': 'abcdef0123456', + }, + ), + # diff protocol + ( + { + 'flags': 0, + 'protocol': 1, + 'algorithm': 2, + 'public_key': 'abcdef0123456', + }, + { + 'flags': 0, + 'protocol': 2, + 'algorithm': 2, + 'public_key': 'abcdef0123456', + }, + ), + # diff algorithm + ( + { + 'flags': 0, + 'protocol': 1, + 'algorithm': 2, + 'public_key': 'abcdef0123456', + }, + { + 'flags': 0, + 'protocol': 1, + 'algorithm': 3, + 'public_key': 'abcdef0123456', + }, + ), + # diff public_key + ( + { + 'flags': 0, + 'protocol': 1, + 'algorithm': 2, + 'public_key': 'abcdef0123456', + }, + { + 'flags': 0, + 'protocol': 1, + 'algorithm': 2, + 'public_key': 'bcdef0123456a', + }, + ), + ): + a = DsValue(a) + self.assertEqual(a, a) + b = DsValue(b) + self.assertEqual(b, b) + self.assertNotEqual(a, b) + self.assertNotEqual(b, a) + self.assertTrue(a < b) + + # empty string won't parse + with self.assertRaises(RrParseError): + DsValue.parse_rdata_text('') + + # single word won't parse + with self.assertRaises(RrParseError): + DsValue.parse_rdata_text('nope') + + # 2nd word won't parse + with self.assertRaises(RrParseError): + DsValue.parse_rdata_text('0 1') + + # 3rd word won't parse + with self.assertRaises(RrParseError): + DsValue.parse_rdata_text('0 1 2') + + # 5th word won't parse + with self.assertRaises(RrParseError): + DsValue.parse_rdata_text('0 1 2 key blah') + + # things ints, will parse + self.assertEqual( + { + 'flags': 'one', + 'protocol': 'two', + 'algorithm': 'three', + 'public_key': 'key', + }, + DsValue.parse_rdata_text('one two three key'), + ) + + # valid + data = { + 'flags': 0, + 'protocol': 1, + 'algorithm': 2, + 'public_key': '99148c81', + } + self.assertEqual(data, DsValue.parse_rdata_text('0 1 2 99148c81')) + self.assertEqual([], DsValue.validate(data, 'DS')) + + # missing flags + data = {'protocol': 1, 'algorithm': 2, 'public_key': '99148c81'} + self.assertEqual(['missing flags'], DsValue.validate(data, 'DS')) + # invalid flags + data = { + 'flags': 'a', + 'protocol': 1, + 'algorithm': 2, + 'public_key': '99148c81', + } + self.assertEqual(['invalid flags "a"'], DsValue.validate(data, 'DS')) + + # missing protocol + data = {'flags': 1, 'algorithm': 2, 'public_key': '99148c81'} + self.assertEqual(['missing protocol'], DsValue.validate(data, 'DS')) + # invalid protocol + data = { + 'flags': 1, + 'protocol': 'a', + 'algorithm': 2, + 'public_key': '99148c81', + } + self.assertEqual(['invalid protocol "a"'], DsValue.validate(data, 'DS')) + + # missing algorithm + data = {'flags': 1, 'protocol': 2, 'public_key': '99148c81'} + self.assertEqual(['missing algorithm'], DsValue.validate(data, 'DS')) + # invalid algorithm + data = { + 'flags': 1, + 'protocol': 2, + 'algorithm': 'a', + 'public_key': '99148c81', + } + self.assertEqual( + ['invalid algorithm "a"'], DsValue.validate(data, 'DS') + ) + + # missing algorithm (list) + data = {'flags': 1, 'protocol': 2, 'algorithm': 3} + self.assertEqual(['missing public_key'], DsValue.validate([data], 'DS')) + + zone = Zone('unit.tests.', []) + values = [ + { + 'flags': 0, + 'protocol': 1, + 'algorithm': 2, + 'public_key': '99148c81', + }, + { + 'flags': 1, + 'protocol': 2, + 'algorithm': 3, + 'public_key': '99148c44', + }, + ] + a = DsRecord(zone, 'ds', {'ttl': 32, 'values': values}) + self.assertEqual(0, a.values[0].flags) + a.values[0].flags += 1 + self.assertEqual(1, a.values[0].flags) + + self.assertEqual(1, a.values[0].protocol) + a.values[0].protocol += 1 + self.assertEqual(2, a.values[0].protocol) + + self.assertEqual(2, a.values[0].algorithm) + a.values[0].algorithm += 1 + self.assertEqual(3, a.values[0].algorithm) + + self.assertEqual('99148c81', a.values[0].public_key) + a.values[0].public_key = '99148c42' + self.assertEqual('99148c42', a.values[0].public_key) + + self.assertEqual(1, a.values[1].flags) + self.assertEqual(2, a.values[1].protocol) + self.assertEqual(3, a.values[1].algorithm) + self.assertEqual('99148c44', a.values[1].public_key) + + self.assertEqual(DsValue(values[1]), a.values[1].data) + self.assertEqual('1 2 3 99148c44', a.values[1].rdata_text) + self.assertEqual('1 2 3 99148c44', a.values[1].__repr__()) diff --git a/tests/test_octodns_record_dynamic.py b/tests/test_octodns_record_dynamic.py new file mode 100644 index 0000000..a361eb3 --- /dev/null +++ b/tests/test_octodns_record_dynamic.py @@ -0,0 +1,1284 @@ +# +# +# + +from unittest import TestCase + +from octodns.idna import idna_encode +from octodns.record import Record +from octodns.record.a import ARecord, Ipv4Value +from octodns.record.aaaa import AaaaRecord +from octodns.record.cname import CnameRecord +from octodns.record.dynamic import _Dynamic, _DynamicPool, _DynamicRule +from octodns.record.exception import ValidationError +from octodns.zone import Zone + +from helpers import SimpleProvider, DynamicProvider + + +class TestRecordDynamic(TestCase): + zone = Zone('unit.tests.', []) + + def test_dynamic_record_copy(self): + a_data = { + 'dynamic': { + 'pools': {'one': {'values': [{'value': '3.3.3.3'}]}}, + 'rules': [{'pool': 'one'}], + }, + 'octodns': {'healthcheck': {'protocol': 'TCP', 'port': 80}}, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + record1 = Record.new(self.zone, 'a', a_data) + record2 = record1.copy() + self.assertEqual(record1._octodns, record2._octodns) + + def test_healthcheck(self): + new = Record.new( + self.zone, + 'a', + { + 'ttl': 44, + 'type': 'A', + 'value': '1.2.3.4', + 'octodns': { + 'healthcheck': { + 'path': '/_ready', + 'host': 'bleep.bloop', + 'protocol': 'HTTP', + 'port': 8080, + } + }, + }, + ) + self.assertEqual('/_ready', new.healthcheck_path) + self.assertEqual('bleep.bloop', new.healthcheck_host()) + self.assertEqual('HTTP', new.healthcheck_protocol) + self.assertEqual(8080, new.healthcheck_port) + + # empty host value in healthcheck + new = Record.new( + self.zone, + 'a', + { + 'ttl': 44, + 'type': 'A', + 'value': '1.2.3.4', + 'octodns': { + 'healthcheck': { + 'path': '/_ready', + 'host': None, + 'protocol': 'HTTP', + 'port': 8080, + } + }, + }, + ) + self.assertEqual('1.2.3.4', new.healthcheck_host(value="1.2.3.4")) + + new = Record.new( + self.zone, 'a', {'ttl': 44, 'type': 'A', 'value': '1.2.3.4'} + ) + self.assertEqual('/_dns', new.healthcheck_path) + self.assertEqual('a.unit.tests', new.healthcheck_host()) + self.assertEqual('HTTPS', new.healthcheck_protocol) + self.assertEqual(443, new.healthcheck_port) + + def test_healthcheck_tcp(self): + new = Record.new( + self.zone, + 'a', + { + 'ttl': 44, + 'type': 'A', + 'value': '1.2.3.4', + 'octodns': { + 'healthcheck': { + 'path': '/ignored', + 'host': 'completely.ignored', + 'protocol': 'TCP', + 'port': 8080, + } + }, + }, + ) + self.assertIsNone(new.healthcheck_path) + self.assertIsNone(new.healthcheck_host()) + self.assertEqual('TCP', new.healthcheck_protocol) + self.assertEqual(8080, new.healthcheck_port) + + new = Record.new( + self.zone, + 'a', + { + 'ttl': 44, + 'type': 'A', + 'value': '1.2.3.4', + 'octodns': {'healthcheck': {'protocol': 'TCP'}}, + }, + ) + self.assertIsNone(new.healthcheck_path) + self.assertIsNone(new.healthcheck_host()) + self.assertEqual('TCP', new.healthcheck_protocol) + self.assertEqual(443, new.healthcheck_port) + + def test_simple_a_weighted(self): + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'weight': 10, 'value': '3.3.3.3'}]}, + 'two': { + # Testing out of order value sorting here + 'values': [{'value': '5.5.5.5'}, {'value': '4.4.4.4'}] + }, + 'three': { + 'values': [ + {'weight': 10, 'value': '4.4.4.4'}, + {'weight': 12, 'value': '5.5.5.5'}, + ] + }, + }, + 'rules': [ + {'geos': ['AF', 'EU'], 'pool': 'three'}, + {'geos': ['NA-US-CA'], 'pool': 'two'}, + {'pool': 'one'}, + ], + }, + 'ttl': 60, + 'values': ['1.1.1.1', '2.2.2.2'], + } + a = ARecord(self.zone, 'weighted', a_data) + self.assertEqual('A', a._type) + self.assertEqual(a_data['ttl'], a.ttl) + self.assertEqual(a_data['values'], a.values) + + dynamic = a.dynamic + self.assertTrue(dynamic) + + pools = dynamic.pools + self.assertTrue(pools) + self.assertEqual( + {'value': '3.3.3.3', 'weight': 1, 'status': 'obey'}, + pools['one'].data['values'][0], + ) + self.assertEqual( + [ + {'value': '4.4.4.4', 'weight': 1, 'status': 'obey'}, + {'value': '5.5.5.5', 'weight': 1, 'status': 'obey'}, + ], + pools['two'].data['values'], + ) + self.assertEqual( + [ + {'weight': 10, 'value': '4.4.4.4', 'status': 'obey'}, + {'weight': 12, 'value': '5.5.5.5', 'status': 'obey'}, + ], + pools['three'].data['values'], + ) + + rules = dynamic.rules + self.assertTrue(rules) + self.assertEqual(a_data['dynamic']['rules'][0], rules[0].data) + + # smoke test of _DynamicMixin.__repr__ + a.__repr__() + delattr(a, 'values') + a.value = 'abc' + a.__repr__() + + def test_simple_aaaa_weighted(self): + aaaa_data = { + 'dynamic': { + 'pools': { + 'one': '2601:642:500:e210:62f8:1dff:feb8:9473', + 'two': [ + '2601:642:500:e210:62f8:1dff:feb8:9474', + '2601:642:500:e210:62f8:1dff:feb8:9475', + ], + 'three': { + 1: '2601:642:500:e210:62f8:1dff:feb8:9476', + 2: '2601:642:500:e210:62f8:1dff:feb8:9477', + }, + }, + 'rules': [{'pools': ['three', 'two', 'one']}], + }, + 'ttl': 60, + 'values': [ + '2601:642:500:e210:62f8:1dff:feb8:9471', + '2601:642:500:e210:62f8:1dff:feb8:9472', + ], + } + aaaa_data = { + 'dynamic': { + 'pools': { + 'one': { + 'values': [ + {'value': '2601:642:500:e210:62f8:1dff:feb8:9473'} + ] + }, + 'two': { + # Testing out of order value sorting here + 'values': [ + {'value': '2601:642:500:e210:62f8:1dff:feb8:9475'}, + {'value': '2601:642:500:e210:62f8:1dff:feb8:9474'}, + ] + }, + 'three': { + 'values': [ + { + 'weight': 10, + 'value': '2601:642:500:e210:62f8:1dff:feb8:9476', + }, + { + 'weight': 12, + 'value': '2601:642:500:e210:62f8:1dff:feb8:9477', + }, + ] + }, + }, + 'rules': [ + {'geos': ['AF', 'EU'], 'pool': 'three'}, + {'geos': ['NA-US-CA'], 'pool': 'two'}, + {'pool': 'one'}, + ], + }, + 'ttl': 60, + 'values': [ + '2601:642:500:e210:62f8:1dff:feb8:9471', + '2601:642:500:e210:62f8:1dff:feb8:9472', + ], + } + aaaa = AaaaRecord(self.zone, 'weighted', aaaa_data) + self.assertEqual('AAAA', aaaa._type) + self.assertEqual(aaaa_data['ttl'], aaaa.ttl) + self.assertEqual(aaaa_data['values'], aaaa.values) + + dynamic = aaaa.dynamic + self.assertTrue(dynamic) + + pools = dynamic.pools + self.assertTrue(pools) + self.assertEqual( + { + 'value': '2601:642:500:e210:62f8:1dff:feb8:9473', + 'weight': 1, + 'status': 'obey', + }, + pools['one'].data['values'][0], + ) + self.assertEqual( + [ + { + 'value': '2601:642:500:e210:62f8:1dff:feb8:9474', + 'weight': 1, + 'status': 'obey', + }, + { + 'value': '2601:642:500:e210:62f8:1dff:feb8:9475', + 'weight': 1, + 'status': 'obey', + }, + ], + pools['two'].data['values'], + ) + self.assertEqual( + [ + { + 'weight': 10, + 'value': '2601:642:500:e210:62f8:1dff:feb8:9476', + 'status': 'obey', + }, + { + 'weight': 12, + 'value': '2601:642:500:e210:62f8:1dff:feb8:9477', + 'status': 'obey', + }, + ], + pools['three'].data['values'], + ) + + rules = dynamic.rules + self.assertTrue(rules) + self.assertEqual(aaaa_data['dynamic']['rules'][0], rules[0].data) + + def test_simple_cname_weighted(self): + cname_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': 'one.cname.target.'}]}, + 'two': {'values': [{'value': 'two.cname.target.'}]}, + 'three': { + 'values': [ + {'weight': 12, 'value': 'three-1.cname.target.'}, + {'weight': 32, 'value': 'three-2.cname.target.'}, + ] + }, + }, + 'rules': [ + {'geos': ['AF', 'EU'], 'pool': 'three'}, + {'geos': ['NA-US-CA'], 'pool': 'two'}, + {'pool': 'one'}, + ], + }, + 'ttl': 60, + 'value': 'cname.target.', + } + cname = CnameRecord(self.zone, 'weighted', cname_data) + self.assertEqual('CNAME', cname._type) + self.assertEqual(cname_data['ttl'], cname.ttl) + self.assertEqual(cname_data['value'], cname.value) + + dynamic = cname.dynamic + self.assertTrue(dynamic) + + pools = dynamic.pools + self.assertTrue(pools) + self.assertEqual( + {'value': 'one.cname.target.', 'weight': 1, 'status': 'obey'}, + pools['one'].data['values'][0], + ) + self.assertEqual( + {'value': 'two.cname.target.', 'weight': 1, 'status': 'obey'}, + pools['two'].data['values'][0], + ) + self.assertEqual( + [ + { + 'value': 'three-1.cname.target.', + 'weight': 12, + 'status': 'obey', + }, + { + 'value': 'three-2.cname.target.', + 'weight': 32, + 'status': 'obey', + }, + ], + pools['three'].data['values'], + ) + + rules = dynamic.rules + self.assertTrue(rules) + self.assertEqual(cname_data['dynamic']['rules'][0], rules[0].data) + + def test_dynamic_validation(self): + # Missing pools + a_data = { + 'dynamic': {'rules': [{'pool': 'one'}]}, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ['missing pools', 'rule 1 undefined pool "one"'], + ctx.exception.reasons, + ) + + # Empty pools + a_data = { + 'dynamic': {'pools': {}, 'rules': [{'pool': 'one'}]}, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ['missing pools', 'rule 1 undefined pool "one"'], + ctx.exception.reasons, + ) + + # pools not a dict + a_data = { + 'dynamic': {'pools': [], 'rules': [{'pool': 'one'}]}, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ['pools must be a dict', 'rule 1 undefined pool "one"'], + ctx.exception.reasons, + ) + + # Invalid addresses + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': 'this-aint-right'}]}, + 'two': { + 'fallback': 'one', + 'values': [ + {'value': '4.4.4.4'}, + {'value': 'nor-is-this'}, + ], + }, + 'three': { + 'fallback': 'two', + 'values': [ + {'weight': 1, 'value': '5.5.5.5'}, + {'weight': 2, 'value': 'yet-another-bad-one'}, + ], + }, + }, + 'rules': [ + {'geos': ['AF', 'EU'], 'pool': 'three'}, + {'geos': ['NA-US-CA'], 'pool': 'two'}, + {'pool': 'one'}, + ], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + [ + 'invalid IPv4 address "this-aint-right"', + 'invalid IPv4 address "yet-another-bad-one"', + 'invalid IPv4 address "nor-is-this"', + ], + ctx.exception.reasons, + ) + + # missing value(s) + a_data = { + 'dynamic': { + 'pools': { + 'one': {}, + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + 'three': { + 'values': [ + {'weight': 1, 'value': '6.6.6.6'}, + {'weight': 2, 'value': '7.7.7.7'}, + ] + }, + }, + 'rules': [ + {'geos': ['AF', 'EU'], 'pool': 'three'}, + {'geos': ['NA-US-CA'], 'pool': 'two'}, + {'pool': 'one'}, + ], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ['pool "one" is missing values'], ctx.exception.reasons + ) + + # pool value not a dict + a_data = { + 'dynamic': { + 'pools': { + 'one': '', + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + 'three': { + 'values': [ + {'weight': 1, 'value': '6.6.6.6'}, + {'weight': 2, 'value': '7.7.7.7'}, + ] + }, + }, + 'rules': [ + {'geos': ['AF', 'EU'], 'pool': 'three'}, + {'geos': ['NA-US-CA'], 'pool': 'two'}, + {'pool': 'one'}, + ], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual(['pool "one" must be a dict'], ctx.exception.reasons) + + # empty pool value + a_data = { + 'dynamic': { + 'pools': { + 'one': {}, + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + 'three': { + 'values': [ + {'weight': 1, 'value': '6.6.6.6'}, + {'weight': 2, 'value': '7.7.7.7'}, + ] + }, + }, + 'rules': [ + {'geos': ['AF', 'EU'], 'pool': 'three'}, + {'geos': ['NA-US-CA'], 'pool': 'two'}, + {'pool': 'one'}, + ], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ['pool "one" is missing values'], ctx.exception.reasons + ) + + # invalid int weight + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + 'three': { + 'values': [ + {'weight': 1, 'value': '6.6.6.6'}, + {'weight': 101, 'value': '7.7.7.7'}, + ] + }, + }, + 'rules': [ + {'geos': ['AF', 'EU'], 'pool': 'three'}, + {'geos': ['NA-US-CA'], 'pool': 'two'}, + {'pool': 'one'}, + ], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ['invalid weight "101" in pool "three" value 2'], + ctx.exception.reasons, + ) + + # invalid non-int weight + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + 'three': { + 'values': [ + {'weight': 1, 'value': '6.6.6.6'}, + {'weight': 'foo', 'value': '7.7.7.7'}, + ] + }, + }, + 'rules': [ + {'geos': ['AF', 'EU'], 'pool': 'three'}, + {'geos': ['NA-US-CA'], 'pool': 'two'}, + {'pool': 'one'}, + ], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ['invalid weight "foo" in pool "three" value 2'], + ctx.exception.reasons, + ) + + # single value with weight!=1 + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'weight': 12, 'value': '6.6.6.6'}]} + }, + 'rules': [{'pool': 'one'}], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ['pool "one" has single value with weight!=1'], + ctx.exception.reasons, + ) + + # invalid fallback + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'fallback': 'invalid', + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}], + }, + 'three': { + 'fallback': 'two', + 'values': [ + {'weight': 1, 'value': '6.6.6.6'}, + {'weight': 5, 'value': '7.7.7.7'}, + ], + }, + }, + 'rules': [ + {'geos': ['AF', 'EU'], 'pool': 'three'}, + {'geos': ['NA-US-CA'], 'pool': 'two'}, + {'pool': 'one'}, + ], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ['undefined fallback "invalid" for pool "two"'], + ctx.exception.reasons, + ) + + # fallback loop + a_data = { + 'dynamic': { + 'pools': { + 'one': { + 'fallback': 'three', + 'values': [{'value': '3.3.3.3'}], + }, + 'two': { + 'fallback': 'one', + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}], + }, + 'three': { + 'fallback': 'two', + 'values': [ + {'weight': 1, 'value': '6.6.6.6'}, + {'weight': 5, 'value': '7.7.7.7'}, + ], + }, + }, + 'rules': [ + {'geos': ['AF', 'EU'], 'pool': 'three'}, + {'geos': ['NA-US-CA'], 'pool': 'two'}, + {'pool': 'one'}, + ], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + [ + 'loop in pool fallbacks: one -> three -> two', + 'loop in pool fallbacks: three -> two -> one', + 'loop in pool fallbacks: two -> one -> three', + ], + ctx.exception.reasons, + ) + + # multiple pool problems + a_data = { + 'dynamic': { + 'pools': { + 'one': '', + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': 'blip'}] + }, + 'three': { + 'values': [ + {'weight': 1}, + {'weight': 5000, 'value': '7.7.7.7'}, + ] + }, + }, + 'rules': [ + {'geos': ['AF', 'EU'], 'pool': 'three'}, + {'geos': ['NA-US-CA'], 'pool': 'two'}, + {'pool': 'one'}, + ], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + [ + 'pool "one" must be a dict', + 'missing value in pool "three" value 1', + 'invalid weight "5000" in pool "three" value 2', + 'invalid IPv4 address "blip"', + ], + ctx.exception.reasons, + ) + + # missing rules, and unused pools + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + } + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ['missing rules', 'unused pools: "one", "two"'], + ctx.exception.reasons, + ) + + # empty rules + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + }, + 'rules': [], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ['missing rules', 'unused pools: "one", "two"'], + ctx.exception.reasons, + ) + + # rules not a list/tuple + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + }, + 'rules': {}, + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ['rules must be a list', 'unused pools: "one", "two"'], + ctx.exception.reasons, + ) + + # rule without pool + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + }, + 'rules': [{'geos': ['NA-US-CA']}, {'pool': 'one'}], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ['rule 1 missing pool', 'unused pools: "two"'], + ctx.exception.reasons, + ) + + # rule with non-string pools + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + }, + 'rules': [{'geos': ['NA-US-CA'], 'pool': []}, {'pool': 'one'}], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ['rule 1 invalid pool "[]"', 'unused pools: "two"'], + ctx.exception.reasons, + ) + + # rule references non-existent pool + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + }, + 'rules': [ + {'geos': ['NA-US-CA'], 'pool': 'non-existent'}, + {'pool': 'one'}, + ], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ["rule 1 undefined pool \"non-existent\"", 'unused pools: "two"'], + ctx.exception.reasons, + ) + + # rule with invalid geos + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + }, + 'rules': [{'geos': 'NA-US-CA', 'pool': 'two'}, {'pool': 'one'}], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual(['rule 1 geos must be a list'], ctx.exception.reasons) + + # rule with invalid geo + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + }, + 'rules': [ + {'geos': ['invalid'], 'pool': 'two'}, + {'pool': 'one'}, + ], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ['rule 1 unknown continent code "invalid"'], ctx.exception.reasons + ) + + # multiple default rules + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + }, + 'rules': [{'pool': 'two'}, {'pool': 'one'}], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual(['rule 2 duplicate default'], ctx.exception.reasons) + + # repeated pool in rules + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + }, + 'rules': [ + {'geos': ['EU'], 'pool': 'two'}, + {'geos': ['AF'], 'pool': 'one'}, + {'geos': ['OC'], 'pool': 'one'}, + ], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ['rule 3 invalid, target pool "one" reused'], ctx.exception.reasons + ) + + # Repeated pool is OK if later one is a default + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + }, + 'rules': [ + {'geos': ['EU-GB'], 'pool': 'one'}, + {'geos': ['EU'], 'pool': 'two'}, + {'pool': 'one'}, + ], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + # This should be valid, no exception + Record.new(self.zone, 'bad', a_data) + + # invalid status + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '2.2.2.2', 'status': 'none'}]} + }, + 'rules': [{'pool': 'one'}], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertIn('invalid status', ctx.exception.reasons[0]) + + def test_dynamic_lenient(self): + # Missing pools + a_data = { + 'dynamic': { + 'rules': [{'geos': ['EU'], 'pool': 'two'}, {'pool': 'one'}] + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + a = Record.new(self.zone, 'bad', a_data, lenient=True) + self.assertEqual( + {'pools': {}, 'rules': a_data['dynamic']['rules']}, + a._data()['dynamic'], + ) + + # Missing rule + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [ + {'value': '4.4.4.4'}, + {'value': '5.5.5.5', 'weight': 2}, + ] + }, + } + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + a = Record.new(self.zone, 'bad', a_data, lenient=True) + self.assertEqual( + { + 'pools': { + 'one': { + 'fallback': None, + 'values': [ + {'value': '3.3.3.3', 'weight': 1, 'status': 'obey'} + ], + }, + 'two': { + 'fallback': None, + 'values': [ + {'value': '4.4.4.4', 'weight': 1, 'status': 'obey'}, + {'value': '5.5.5.5', 'weight': 2, 'status': 'obey'}, + ], + }, + }, + 'rules': [], + }, + a._data()['dynamic'], + ) + + # rule without pool + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [ + {'value': '4.4.4.4'}, + {'value': '5.5.5.5', 'weight': 2}, + ] + }, + }, + 'rules': [{'geos': ['EU'], 'pool': 'two'}, {}], + }, + 'ttl': 60, + 'type': 'A', + 'values': ['1.1.1.1', '2.2.2.2'], + } + a = Record.new(self.zone, 'bad', a_data, lenient=True) + self.assertEqual( + { + 'pools': { + 'one': { + 'fallback': None, + 'values': [ + {'value': '3.3.3.3', 'weight': 1, 'status': 'obey'} + ], + }, + 'two': { + 'fallback': None, + 'values': [ + {'value': '4.4.4.4', 'weight': 1, 'status': 'obey'}, + {'value': '5.5.5.5', 'weight': 2, 'status': 'obey'}, + ], + }, + }, + 'rules': a_data['dynamic']['rules'], + }, + a._data()['dynamic'], + ) + + def test_dynamic_changes(self): + simple = SimpleProvider() + dynamic = DynamicProvider() + + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + }, + 'rules': [{'geos': ['EU'], 'pool': 'two'}, {'pool': 'one'}], + }, + 'ttl': 60, + 'values': ['1.1.1.1', '2.2.2.2'], + } + a = ARecord(self.zone, 'weighted', a_data) + dup = ARecord(self.zone, 'weighted', a_data) + + b_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [ + {'value': '4.4.4.4', 'weight': 2}, + {'value': '5.5.5.5'}, + ] + }, + }, + 'rules': [{'geos': ['EU'], 'pool': 'two'}, {'pool': 'one'}], + }, + 'ttl': 60, + 'values': ['1.1.1.1', '2.2.2.2'], + } + b = ARecord(self.zone, 'weighted', b_data) + + c_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [{'value': '4.4.4.4'}, {'value': '5.5.5.5'}] + }, + }, + 'rules': [{'geos': ['NA'], 'pool': 'two'}, {'pool': 'one'}], + }, + 'ttl': 60, + 'values': ['1.1.1.1', '2.2.2.2'], + } + c = ARecord(self.zone, 'weighted', c_data) + + # a changes a (identical dup) is never true + self.assertFalse(a.changes(dup, simple)) + self.assertFalse(a.changes(dup, dynamic)) + + # a changes b is not true for simple + self.assertFalse(a.changes(b, simple)) + # but is true for dynamic + update = a.changes(b, dynamic) + self.assertEqual(a, update.existing) + self.assertEqual(b, update.new) + # transitive + self.assertFalse(b.changes(a, simple)) + update = b.changes(a, dynamic) + self.assertEqual(a, update.existing) + self.assertEqual(b, update.new) + + # same for a change c + self.assertFalse(a.changes(c, simple)) + self.assertTrue(a.changes(c, dynamic)) + self.assertFalse(c.changes(a, simple)) + self.assertTrue(c.changes(a, dynamic)) + + # smoke test some of the equiality bits + self.assertEqual(a.dynamic.pools, a.dynamic.pools) + self.assertEqual(a.dynamic.pools['one'], a.dynamic.pools['one']) + self.assertNotEqual(a.dynamic.pools['one'], a.dynamic.pools['two']) + self.assertEqual(a.dynamic.rules, a.dynamic.rules) + self.assertEqual(a.dynamic.rules[0], a.dynamic.rules[0]) + self.assertNotEqual(a.dynamic.rules[0], c.dynamic.rules[0]) + + def test_dynamic_and_geo_validation(self): + a_data = { + 'dynamic': { + 'pools': { + 'one': {'values': [{'value': '3.3.3.3'}]}, + 'two': { + 'values': [{'value': '5.5.5.5'}, {'value': '4.4.4.4'}] + }, + 'three': { + 'values': [ + {'weight': 10, 'value': '4.4.4.4'}, + {'weight': 12, 'value': '5.5.5.5'}, + ] + }, + }, + 'rules': [ + {'geos': ['AF', 'EU'], 'pool': 'three'}, + {'geos': ['NA-US-CA'], 'pool': 'two'}, + {'pool': 'one'}, + ], + }, + 'geo': {'NA': ['1.2.3.5'], 'NA-US': ['1.2.3.5', '1.2.3.6']}, + 'type': 'A', + 'ttl': 60, + 'values': ['1.1.1.1', '2.2.2.2'], + } + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, 'bad', a_data) + self.assertEqual( + ['"dynamic" record with "geo" content'], ctx.exception.reasons + ) + + def test_dynamic_eqs(self): + pool_one = _DynamicPool( + 'one', {'values': [{'value': '1.2.3.4'}]}, Ipv4Value + ) + pool_two = _DynamicPool( + 'two', {'values': [{'value': '1.2.3.5'}]}, Ipv4Value + ) + self.assertEqual(pool_one, pool_one) + self.assertNotEqual(pool_one, pool_two) + self.assertNotEqual(pool_one, 42) + + pools = {'one': pool_one, 'two': pool_two} + rule_one = _DynamicRule(0, {'pool': 'one'}) + rule_two = _DynamicRule(1, {'pool': 'two'}) + self.assertEqual(rule_one, rule_one) + self.assertNotEqual(rule_one, rule_two) + self.assertNotEqual(rule_one, 42) + rules = [rule_one, rule_two] + + dynamic = _Dynamic(pools, rules) + other = _Dynamic({}, []) + self.assertEqual(dynamic, dynamic) + self.assertNotEqual(dynamic, other) + self.assertNotEqual(dynamic, 42) + + def test_dynamic_cname_idna(self): + a_utf8 = 'natación.mx.' + a_encoded = idna_encode(a_utf8) + b_utf8 = 'гэрбүл.mn.' + b_encoded = idna_encode(b_utf8) + cname_data = { + 'dynamic': { + 'pools': { + 'one': { + # Testing out of order value sorting here + 'values': [ + {'value': 'b.unit.tests.'}, + {'value': 'a.unit.tests.'}, + ] + }, + 'two': { + 'values': [ + # some utf8 values we expect to be idna encoded + {'weight': 10, 'value': a_utf8}, + {'weight': 12, 'value': b_utf8}, + ] + }, + }, + 'rules': [ + {'geos': ['NA-US-CA'], 'pool': 'two'}, + {'pool': 'one'}, + ], + }, + 'type': 'CNAME', + 'ttl': 60, + 'value': a_utf8, + } + cname = Record.new(self.zone, 'cname', cname_data) + self.assertEqual(a_encoded, cname.value) + self.assertEqual( + { + 'fallback': None, + 'values': [ + {'weight': 1, 'value': 'a.unit.tests.', 'status': 'obey'}, + {'weight': 1, 'value': 'b.unit.tests.', 'status': 'obey'}, + ], + }, + cname.dynamic.pools['one'].data, + ) + self.assertEqual( + { + 'fallback': None, + 'values': [ + {'weight': 12, 'value': b_encoded, 'status': 'obey'}, + {'weight': 10, 'value': a_encoded, 'status': 'obey'}, + ], + }, + cname.dynamic.pools['two'].data, + ) diff --git a/tests/test_octodns_record_geo.py b/tests/test_octodns_record_geo.py index c7b15a3..3f0b858 100644 --- a/tests/test_octodns_record_geo.py +++ b/tests/test_octodns_record_geo.py @@ -4,10 +4,74 @@ from unittest import TestCase -from octodns.record.geo import GeoCodes +from octodns.record import Record +from octodns.record.a import ARecord +from octodns.record.geo import GeoCodes, GeoValue +from octodns.record.exception import ValidationError +from octodns.zone import Zone + +from helpers import SimpleProvider, GeoProvider + + +class TestRecordGeo(TestCase): + zone = Zone('unit.tests.', []) + + def test_geo(self): + geo_data = { + 'ttl': 42, + 'values': ['5.2.3.4', '6.2.3.4'], + 'geo': { + 'AF': ['1.1.1.1'], + 'AS-JP': ['2.2.2.2', '3.3.3.3'], + 'NA-US': ['4.4.4.4', '5.5.5.5'], + 'NA-US-CA': ['6.6.6.6', '7.7.7.7'], + }, + } + geo = ARecord(self.zone, 'geo', geo_data) + self.assertEqual(geo_data, geo.data) + + other_data = { + 'ttl': 42, + 'values': ['5.2.3.4', '6.2.3.4'], + 'geo': { + 'AF': ['1.1.1.1'], + 'AS-JP': ['2.2.2.2', '3.3.3.3'], + 'NA-US': ['4.4.4.4', '5.5.5.5'], + 'NA-US-CA': ['6.6.6.6', '7.7.7.7'], + }, + } + other = ARecord(self.zone, 'geo', other_data) + self.assertEqual(other_data, other.data) + + simple_target = SimpleProvider() + geo_target = GeoProvider() + + # Geo provider doesn't consider identical geo to be changes + self.assertFalse(geo.changes(geo, geo_target)) + + # geo values don't impact equality + other.geo['AF'].values = ['9.9.9.9'] + self.assertTrue(geo == other) + # Non-geo supporting provider doesn't consider geo diffs to be changes + self.assertFalse(geo.changes(other, simple_target)) + # Geo provider does consider geo diffs to be changes + self.assertTrue(geo.changes(other, geo_target)) + + # Object without geo doesn't impact equality + other.geo = {} + self.assertTrue(geo == other) + # Non-geo supporting provider doesn't consider lack of geo a diff + self.assertFalse(geo.changes(other, simple_target)) + # Geo provider does consider lack of geo diffs to be changes + self.assertTrue(geo.changes(other, geo_target)) + + # __repr__ doesn't blow up + geo.__repr__() class TestRecordGeoCodes(TestCase): + zone = Zone('unit.tests.', []) + def test_validate(self): prefix = 'xyz ' @@ -104,3 +168,122 @@ class TestRecordGeoCodes(TestCase): self.assertEqual('NA-CA-AB', GeoCodes.province_to_code('AB')) self.assertEqual('NA-CA-BC', GeoCodes.province_to_code('BC')) self.assertFalse(GeoCodes.province_to_code('XX')) + + def test_geo_value(self): + code = 'NA-US-CA' + values = ['1.2.3.4'] + geo = GeoValue(code, values) + self.assertEqual(code, geo.code) + self.assertEqual('NA', geo.continent_code) + self.assertEqual('US', geo.country_code) + self.assertEqual('CA', geo.subdivision_code) + self.assertEqual(values, geo.values) + self.assertEqual(['NA-US', 'NA'], list(geo.parents)) + + a = GeoValue('NA-US-CA', values) + b = GeoValue('AP-JP', values) + c = GeoValue('NA-US-CA', ['2.3.4.5']) + + self.assertEqual(a, a) + self.assertEqual(b, b) + self.assertEqual(c, c) + + self.assertNotEqual(a, b) + self.assertNotEqual(a, c) + self.assertNotEqual(b, a) + self.assertNotEqual(b, c) + self.assertNotEqual(c, a) + self.assertNotEqual(c, b) + + self.assertTrue(a > b) + self.assertTrue(a < c) + self.assertTrue(b < a) + self.assertTrue(b < c) + self.assertTrue(c > a) + self.assertTrue(c > b) + + self.assertTrue(a >= a) + self.assertTrue(a >= b) + self.assertTrue(a <= c) + self.assertTrue(b <= a) + self.assertTrue(b <= b) + self.assertTrue(b <= c) + self.assertTrue(c > a) + self.assertTrue(c > b) + self.assertTrue(c >= b) + + def test_validation(self): + Record.new( + self.zone, + '', + { + 'geo': {'NA': ['1.2.3.5'], 'NA-US': ['1.2.3.5', '1.2.3.6']}, + 'type': 'A', + 'ttl': 600, + 'value': '1.2.3.4', + }, + ) + + # invalid ip address + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'geo': {'NA': ['hello'], 'NA-US': ['1.2.3.5', '1.2.3.6']}, + 'type': 'A', + 'ttl': 600, + 'value': '1.2.3.4', + }, + ) + self.assertEqual( + ['invalid IPv4 address "hello"'], ctx.exception.reasons + ) + + # invalid geo code + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'geo': {'XYZ': ['1.2.3.4']}, + 'type': 'A', + 'ttl': 600, + 'value': '1.2.3.4', + }, + ) + self.assertEqual(['invalid geo "XYZ"'], ctx.exception.reasons) + + # invalid ip address + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'geo': {'NA': ['hello'], 'NA-US': ['1.2.3.5', 'goodbye']}, + 'type': 'A', + 'ttl': 600, + 'value': '1.2.3.4', + }, + ) + self.assertEqual( + ['invalid IPv4 address "hello"', 'invalid IPv4 address "goodbye"'], + ctx.exception.reasons, + ) + + # invalid healthcheck protocol + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + 'a', + { + 'geo': {'NA': ['1.2.3.5'], 'NA-US': ['1.2.3.5', '1.2.3.6']}, + 'type': 'A', + 'ttl': 600, + 'value': '1.2.3.4', + 'octodns': {'healthcheck': {'protocol': 'FTP'}}, + }, + ) + self.assertEqual( + ['invalid healthcheck protocol'], ctx.exception.reasons + ) diff --git a/tests/test_octodns_record_ip.py b/tests/test_octodns_record_ip.py new file mode 100644 index 0000000..29d576f --- /dev/null +++ b/tests/test_octodns_record_ip.py @@ -0,0 +1,30 @@ +# +# +# + +from unittest import TestCase + +from octodns.record.a import ARecord, Ipv4Value +from octodns.zone import Zone + + +class TestRecordIp(TestCase): + def test_ipv4_value_rdata_text(self): + + # anything goes, we're a noop + for s in ( + None, + '', + 'word', + 42, + 42.43, + '1.2.3', + 'some.words.that.here', + '1.2.word.4', + '1.2.3.4', + ): + self.assertEqual(s, Ipv4Value.parse_rdata_text(s)) + + zone = Zone('unit.tests.', []) + a = ARecord(zone, 'a', {'ttl': 42, 'value': '1.2.3.4'}) + self.assertEqual('1.2.3.4', a.values[0].rdata_text) diff --git a/tests/test_octodns_record_loc.py b/tests/test_octodns_record_loc.py new file mode 100644 index 0000000..c59aa2d --- /dev/null +++ b/tests/test_octodns_record_loc.py @@ -0,0 +1,697 @@ +# +# +# + +from unittest import TestCase + +from octodns.record import Record +from octodns.record.loc import LocRecord, LocValue +from octodns.record.exception import ValidationError +from octodns.record.rr import RrParseError +from octodns.zone import Zone + +from helpers import SimpleProvider + + +class TestRecordLoc(TestCase): + zone = Zone('unit.tests.', []) + + def test_loc(self): + a_values = [ + LocValue( + { + 'lat_degrees': 31, + 'lat_minutes': 58, + 'lat_seconds': 52.1, + 'lat_direction': 'S', + 'long_degrees': 115, + 'long_minutes': 49, + 'long_seconds': 11.7, + 'long_direction': 'E', + 'altitude': 20, + 'size': 10, + 'precision_horz': 10, + 'precision_vert': 2, + } + ) + ] + a_data = {'ttl': 30, 'values': a_values} + a = LocRecord(self.zone, 'a', a_data) + self.assertEqual('a', a.name) + self.assertEqual('a.unit.tests.', a.fqdn) + self.assertEqual(30, a.ttl) + self.assertEqual(a_values[0]['lat_degrees'], a.values[0].lat_degrees) + self.assertEqual(a_values[0]['lat_minutes'], a.values[0].lat_minutes) + self.assertEqual(a_values[0]['lat_seconds'], a.values[0].lat_seconds) + self.assertEqual( + a_values[0]['lat_direction'], a.values[0].lat_direction + ) + self.assertEqual(a_values[0]['long_degrees'], a.values[0].long_degrees) + self.assertEqual(a_values[0]['long_minutes'], a.values[0].long_minutes) + self.assertEqual(a_values[0]['long_seconds'], a.values[0].long_seconds) + self.assertEqual( + a_values[0]['long_direction'], a.values[0].long_direction + ) + self.assertEqual(a_values[0]['altitude'], a.values[0].altitude) + self.assertEqual(a_values[0]['size'], a.values[0].size) + self.assertEqual( + a_values[0]['precision_horz'], a.values[0].precision_horz + ) + self.assertEqual( + a_values[0]['precision_vert'], a.values[0].precision_vert + ) + + b_value = LocValue( + { + 'lat_degrees': 32, + 'lat_minutes': 7, + 'lat_seconds': 19, + 'lat_direction': 'S', + 'long_degrees': 116, + 'long_minutes': 2, + 'long_seconds': 25, + 'long_direction': 'E', + 'altitude': 10, + 'size': 1, + 'precision_horz': 10000, + 'precision_vert': 10, + } + ) + b_data = {'ttl': 30, 'value': b_value} + b = LocRecord(self.zone, 'b', b_data) + self.assertEqual(b_value['lat_degrees'], b.values[0].lat_degrees) + self.assertEqual(b_value['lat_minutes'], b.values[0].lat_minutes) + self.assertEqual(b_value['lat_seconds'], b.values[0].lat_seconds) + self.assertEqual(b_value['lat_direction'], b.values[0].lat_direction) + self.assertEqual(b_value['long_degrees'], b.values[0].long_degrees) + self.assertEqual(b_value['long_minutes'], b.values[0].long_minutes) + self.assertEqual(b_value['long_seconds'], b.values[0].long_seconds) + self.assertEqual(b_value['long_direction'], b.values[0].long_direction) + self.assertEqual(b_value['altitude'], b.values[0].altitude) + self.assertEqual(b_value['size'], b.values[0].size) + self.assertEqual(b_value['precision_horz'], b.values[0].precision_horz) + self.assertEqual(b_value['precision_vert'], b.values[0].precision_vert) + self.assertEqual(b_data, b.data) + + target = SimpleProvider() + # No changes with self + self.assertFalse(a.changes(a, target)) + # Diff in lat_direction causes change + other = LocRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].lat_direction = 'N' + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in altitude causes change + other.values[0].altitude = a.values[0].altitude + other.values[0].altitude = -10 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + + # __repr__ doesn't blow up + a.__repr__() + + def test_loc_value_rdata_text(self): + # only the exact correct number of words is allowed + for i in tuple(range(0, 12)) + (13,): + s = ''.join(['word'] * i) + with self.assertRaises(RrParseError): + LocValue.parse_rdata_text(s) + + # type conversions are best effort + self.assertEqual( + { + 'altitude': 'six', + 'lat_degrees': 'zero', + 'lat_direction': 'S', + 'lat_minutes': 'one', + 'lat_seconds': 'two', + 'long_degrees': 'three', + 'long_direction': 'W', + 'long_minutes': 'four', + 'long_seconds': 'five', + 'precision_horz': 'eight', + 'precision_vert': 'nine', + 'size': 'seven', + }, + LocValue.parse_rdata_text( + 'zero one two S three four five W six seven eight nine' + ), + ) + + # valid + s = '0 1 2.2 N 3 4 5.5 E 6.6m 7.7m 8.8m 9.9m' + self.assertEqual( + { + 'altitude': 6.6, + 'lat_degrees': 0, + 'lat_direction': 'N', + 'lat_minutes': 1, + 'lat_seconds': 2.2, + 'long_degrees': 3, + 'long_direction': 'E', + 'long_minutes': 4, + 'long_seconds': 5.5, + 'precision_horz': 8.8, + 'precision_vert': 9.9, + 'size': 7.7, + }, + LocValue.parse_rdata_text(s), + ) + + # make sure that the cstor is using parse_rdata_text + zone = Zone('unit.tests.', []) + a = LocRecord( + zone, + 'mx', + { + 'type': 'LOC', + 'ttl': 42, + 'value': { + 'altitude': 6.6, + 'lat_degrees': 0, + 'lat_direction': 'N', + 'lat_minutes': 1, + 'lat_seconds': 2.2, + 'long_degrees': 3, + 'long_direction': 'E', + 'long_minutes': 4, + 'long_seconds': 5.5, + 'precision_horz': 8.8, + 'precision_vert': 9.9, + 'size': 7.7, + }, + }, + ) + self.assertEqual(0, a.values[0].lat_degrees) + self.assertEqual(1, a.values[0].lat_minutes) + self.assertEqual(2.2, a.values[0].lat_seconds) + self.assertEqual('N', a.values[0].lat_direction) + self.assertEqual(3, a.values[0].long_degrees) + self.assertEqual(4, a.values[0].long_minutes) + self.assertEqual(5.5, a.values[0].long_seconds) + self.assertEqual('E', a.values[0].long_direction) + self.assertEqual(6.6, a.values[0].altitude) + self.assertEqual(7.7, a.values[0].size) + self.assertEqual(8.8, a.values[0].precision_horz) + self.assertEqual(9.9, a.values[0].precision_vert) + self.assertEqual(s, a.values[0].rdata_text) + + def test_loc_value(self): + a = LocValue( + { + 'lat_degrees': 31, + 'lat_minutes': 58, + 'lat_seconds': 52.1, + 'lat_direction': 'S', + 'long_degrees': 115, + 'long_minutes': 49, + 'long_seconds': 11.7, + 'long_direction': 'E', + 'altitude': 20, + 'size': 10, + 'precision_horz': 10, + 'precision_vert': 2, + } + ) + b = LocValue( + { + 'lat_degrees': 32, + 'lat_minutes': 7, + 'lat_seconds': 19, + 'lat_direction': 'S', + 'long_degrees': 116, + 'long_minutes': 2, + 'long_seconds': 25, + 'long_direction': 'E', + 'altitude': 10, + 'size': 1, + 'precision_horz': 10000, + 'precision_vert': 10, + } + ) + c = LocValue( + { + 'lat_degrees': 53, + 'lat_minutes': 14, + 'lat_seconds': 10, + 'lat_direction': 'N', + 'long_degrees': 2, + 'long_minutes': 18, + 'long_seconds': 26, + 'long_direction': 'W', + 'altitude': 10, + 'size': 1, + 'precision_horz': 1000, + 'precision_vert': 10, + } + ) + + self.assertEqual(a, a) + self.assertEqual(b, b) + self.assertEqual(c, c) + + self.assertNotEqual(a, b) + self.assertNotEqual(a, c) + self.assertNotEqual(b, a) + self.assertNotEqual(b, c) + self.assertNotEqual(c, a) + self.assertNotEqual(c, b) + + self.assertTrue(a < b) + self.assertTrue(a < c) + + self.assertTrue(b > a) + self.assertTrue(b < c) + + self.assertTrue(c > a) + self.assertTrue(c > b) + + self.assertTrue(a <= b) + self.assertTrue(a <= c) + self.assertTrue(a <= a) + self.assertTrue(a >= a) + + self.assertTrue(b >= a) + self.assertTrue(b <= c) + self.assertTrue(b >= b) + self.assertTrue(b <= b) + + self.assertTrue(c >= a) + self.assertTrue(c >= b) + self.assertTrue(c >= c) + self.assertTrue(c <= c) + + self.assertEqual(31, a.lat_degrees) + a.lat_degrees = a.lat_degrees + 1 + self.assertEqual(32, a.lat_degrees) + + self.assertEqual(58, a.lat_minutes) + a.lat_minutes = a.lat_minutes + 1 + self.assertEqual(59, a.lat_minutes) + + self.assertEqual(52.1, a.lat_seconds) + a.lat_seconds = a.lat_seconds + 1 + self.assertEqual(53.1, a.lat_seconds) + + self.assertEqual('S', a.lat_direction) + a.lat_direction = 'N' + self.assertEqual('N', a.lat_direction) + + self.assertEqual(115, a.long_degrees) + a.long_degrees = a.long_degrees + 1 + self.assertEqual(116, a.long_degrees) + + self.assertEqual(49, a.long_minutes) + a.long_minutes = a.long_minutes + 1 + self.assertEqual(50, a.long_minutes) + + self.assertEqual(11.7, a.long_seconds) + a.long_seconds = a.long_seconds + 1 + self.assertEqual(12.7, a.long_seconds) + + self.assertEqual('E', a.long_direction) + a.long_direction = 'W' + self.assertEqual('W', a.long_direction) + + self.assertEqual(20, a.altitude) + a.altitude = a.altitude + 1 + self.assertEqual(21, a.altitude) + + self.assertEqual(10, a.size) + a.size = a.size + 1 + self.assertEqual(11, a.size) + + self.assertEqual(10, a.precision_horz) + a.precision_horz = a.precision_horz + 1 + self.assertEqual(11, a.precision_horz) + + self.assertEqual(2, a.precision_vert) + a.precision_vert = a.precision_vert + 1 + self.assertEqual(3, a.precision_vert) + + # Hash + values = set() + values.add(a) + self.assertTrue(a in values) + self.assertFalse(b in values) + values.add(b) + self.assertTrue(b in values) + + def test_validation(self): + # doesn't blow up + Record.new( + self.zone, + '', + { + 'type': 'LOC', + 'ttl': 600, + 'value': { + 'lat_degrees': 31, + 'lat_minutes': 58, + 'lat_seconds': 52.1, + 'lat_direction': 'S', + 'long_degrees': 115, + 'long_minutes': 49, + 'long_seconds': 11.7, + 'long_direction': 'E', + 'altitude': 20, + 'size': 10, + 'precision_horz': 10, + 'precision_vert': 2, + }, + }, + ) + + # missing int key + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'LOC', + 'ttl': 600, + 'value': { + 'lat_minutes': 58, + 'lat_seconds': 52.1, + 'lat_direction': 'S', + 'long_degrees': 115, + 'long_minutes': 49, + 'long_seconds': 11.7, + 'long_direction': 'E', + 'altitude': 20, + 'size': 10, + 'precision_horz': 10, + 'precision_vert': 2, + }, + }, + ) + + self.assertEqual(['missing lat_degrees'], ctx.exception.reasons) + + # missing float key + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'LOC', + 'ttl': 600, + 'value': { + 'lat_degrees': 31, + 'lat_minutes': 58, + 'lat_direction': 'S', + 'long_degrees': 115, + 'long_minutes': 49, + 'long_seconds': 11.7, + 'long_direction': 'E', + 'altitude': 20, + 'size': 10, + 'precision_horz': 10, + 'precision_vert': 2, + }, + }, + ) + + self.assertEqual(['missing lat_seconds'], ctx.exception.reasons) + + # missing text key + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'LOC', + 'ttl': 600, + 'value': { + 'lat_degrees': 31, + 'lat_minutes': 58, + 'lat_seconds': 52.1, + 'long_degrees': 115, + 'long_minutes': 49, + 'long_seconds': 11.7, + 'long_direction': 'E', + 'altitude': 20, + 'size': 10, + 'precision_horz': 10, + 'precision_vert': 2, + }, + }, + ) + + self.assertEqual(['missing lat_direction'], ctx.exception.reasons) + + # invalid direction + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'LOC', + 'ttl': 600, + 'value': { + 'lat_degrees': 31, + 'lat_minutes': 58, + 'lat_seconds': 52.1, + 'lat_direction': 'U', + 'long_degrees': 115, + 'long_minutes': 49, + 'long_seconds': 11.7, + 'long_direction': 'E', + 'altitude': 20, + 'size': 10, + 'precision_horz': 10, + 'precision_vert': 2, + }, + }, + ) + + self.assertEqual( + ['invalid direction for lat_direction "U"'], ctx.exception.reasons + ) + + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'LOC', + 'ttl': 600, + 'value': { + 'lat_degrees': 31, + 'lat_minutes': 58, + 'lat_seconds': 52.1, + 'lat_direction': 'S', + 'long_degrees': 115, + 'long_minutes': 49, + 'long_seconds': 11.7, + 'long_direction': 'N', + 'altitude': 20, + 'size': 10, + 'precision_horz': 10, + 'precision_vert': 2, + }, + }, + ) + + self.assertEqual( + ['invalid direction for long_direction "N"'], ctx.exception.reasons + ) + + # invalid degrees + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'LOC', + 'ttl': 600, + 'value': { + 'lat_degrees': 360, + 'lat_minutes': 58, + 'lat_seconds': 52.1, + 'lat_direction': 'S', + 'long_degrees': 115, + 'long_minutes': 49, + 'long_seconds': 11.7, + 'long_direction': 'E', + 'altitude': 20, + 'size': 10, + 'precision_horz': 10, + 'precision_vert': 2, + }, + }, + ) + + self.assertEqual( + ['invalid value for lat_degrees "360"'], ctx.exception.reasons + ) + + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'LOC', + 'ttl': 600, + 'value': { + 'lat_degrees': 'nope', + 'lat_minutes': 58, + 'lat_seconds': 52.1, + 'lat_direction': 'S', + 'long_degrees': 115, + 'long_minutes': 49, + 'long_seconds': 11.7, + 'long_direction': 'E', + 'altitude': 20, + 'size': 10, + 'precision_horz': 10, + 'precision_vert': 2, + }, + }, + ) + + self.assertEqual(['invalid lat_degrees "nope"'], ctx.exception.reasons) + + # invalid minutes + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'LOC', + 'ttl': 600, + 'value': { + 'lat_degrees': 31, + 'lat_minutes': 60, + 'lat_seconds': 52.1, + 'lat_direction': 'S', + 'long_degrees': 115, + 'long_minutes': 49, + 'long_seconds': 11.7, + 'long_direction': 'E', + 'altitude': 20, + 'size': 10, + 'precision_horz': 10, + 'precision_vert': 2, + }, + }, + ) + + self.assertEqual( + ['invalid value for lat_minutes "60"'], ctx.exception.reasons + ) + + # invalid seconds + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'LOC', + 'ttl': 600, + 'value': { + 'lat_degrees': 31, + 'lat_minutes': 58, + 'lat_seconds': 60, + 'lat_direction': 'S', + 'long_degrees': 115, + 'long_minutes': 49, + 'long_seconds': 11.7, + 'long_direction': 'E', + 'altitude': 20, + 'size': 10, + 'precision_horz': 10, + 'precision_vert': 2, + }, + }, + ) + + self.assertEqual( + ['invalid value for lat_seconds "60"'], ctx.exception.reasons + ) + + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'LOC', + 'ttl': 600, + 'value': { + 'lat_degrees': 31, + 'lat_minutes': 58, + 'lat_seconds': 'nope', + 'lat_direction': 'S', + 'long_degrees': 115, + 'long_minutes': 49, + 'long_seconds': 11.7, + 'long_direction': 'E', + 'altitude': 20, + 'size': 10, + 'precision_horz': 10, + 'precision_vert': 2, + }, + }, + ) + + self.assertEqual(['invalid lat_seconds "nope"'], ctx.exception.reasons) + + # invalid altitude + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'LOC', + 'ttl': 600, + 'value': { + 'lat_degrees': 31, + 'lat_minutes': 58, + 'lat_seconds': 52.1, + 'lat_direction': 'S', + 'long_degrees': 115, + 'long_minutes': 49, + 'long_seconds': 11.7, + 'long_direction': 'E', + 'altitude': -666666, + 'size': 10, + 'precision_horz': 10, + 'precision_vert': 2, + }, + }, + ) + + self.assertEqual( + ['invalid value for altitude "-666666"'], ctx.exception.reasons + ) + + # invalid size + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'LOC', + 'ttl': 600, + 'value': { + 'lat_degrees': 31, + 'lat_minutes': 58, + 'lat_seconds': 52.1, + 'lat_direction': 'S', + 'long_degrees': 115, + 'long_minutes': 49, + 'long_seconds': 11.7, + 'long_direction': 'E', + 'altitude': 20, + 'size': 99999999.99, + 'precision_horz': 10, + 'precision_vert': 2, + }, + }, + ) + + self.assertEqual( + ['invalid value for size "99999999.99"'], ctx.exception.reasons + ) diff --git a/tests/test_octodns_record_mx.py b/tests/test_octodns_record_mx.py new file mode 100644 index 0000000..371587e --- /dev/null +++ b/tests/test_octodns_record_mx.py @@ -0,0 +1,265 @@ +# +# +# + +from unittest import TestCase + +from octodns.record import Record +from octodns.record.mx import MxRecord, MxValue +from octodns.record.exception import ValidationError +from octodns.record.rr import RrParseError +from octodns.zone import Zone + +from helpers import SimpleProvider + + +class TestRecordMx(TestCase): + zone = Zone('unit.tests.', []) + + def test_mx(self): + a_values = [ + MxValue({'preference': 10, 'exchange': 'smtp1.'}), + MxValue({'priority': 20, 'value': 'smtp2.'}), + ] + a_data = {'ttl': 30, 'values': a_values} + a = MxRecord(self.zone, 'a', a_data) + self.assertEqual('a', a.name) + self.assertEqual('a.unit.tests.', a.fqdn) + self.assertEqual(30, a.ttl) + self.assertEqual(a_values[0]['preference'], a.values[0].preference) + self.assertEqual(a_values[0]['exchange'], a.values[0].exchange) + self.assertEqual(a_values[1]['preference'], a.values[1].preference) + self.assertEqual(a_values[1]['exchange'], a.values[1].exchange) + a_data['values'][1] = MxValue({'preference': 20, 'exchange': 'smtp2.'}) + self.assertEqual(a_data, a.data) + + b_value = MxValue({'preference': 0, 'exchange': 'smtp3.'}) + b_data = {'ttl': 30, 'value': b_value} + b = MxRecord(self.zone, 'b', b_data) + self.assertEqual(b_value['preference'], b.values[0].preference) + self.assertEqual(b_value['exchange'], b.values[0].exchange) + self.assertEqual(b_data, b.data) + + a_upper_values = [ + {'preference': 10, 'exchange': 'SMTP1.'}, + {'priority': 20, 'value': 'SMTP2.'}, + ] + a_upper_data = {'ttl': 30, 'values': a_upper_values} + a_upper = MxRecord(self.zone, 'a', a_upper_data) + self.assertEqual(a_upper.data, a.data) + + target = SimpleProvider() + # No changes with self + self.assertFalse(a.changes(a, target)) + # Diff in preference causes change + other = MxRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].preference = 22 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in value causes change + other.values[0].preference = a.values[0].preference + other.values[0].exchange = 'smtpX' + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + + # __repr__ doesn't blow up + a.__repr__() + + def test_mx_value_rdata_text(self): + + # empty string won't parse + with self.assertRaises(RrParseError): + MxValue.parse_rdata_text('') + + # single word won't parse + with self.assertRaises(RrParseError): + MxValue.parse_rdata_text('nope') + + # 3rd word won't parse + with self.assertRaises(RrParseError): + MxValue.parse_rdata_text('10 mx.unit.tests. another') + + # preference not an int + self.assertEqual( + {'preference': 'abc', 'exchange': 'mx.unit.tests.'}, + MxValue.parse_rdata_text('abc mx.unit.tests.'), + ) + + # valid + self.assertEqual( + {'preference': 10, 'exchange': 'mx.unit.tests.'}, + MxValue.parse_rdata_text('10 mx.unit.tests.'), + ) + + zone = Zone('unit.tests.', []) + a = MxRecord( + zone, + 'mx', + { + 'ttl': 32, + 'values': [ + {'preference': 11, 'exchange': 'mail1.unit.tests.'}, + {'preference': 12, 'exchange': 'mail2.unit.tests.'}, + ], + }, + ) + self.assertEqual(11, a.values[0].preference) + self.assertEqual('mail1.unit.tests.', a.values[0].exchange) + self.assertEqual('11 mail1.unit.tests.', a.values[0].rdata_text) + self.assertEqual(12, a.values[1].preference) + self.assertEqual('mail2.unit.tests.', a.values[1].exchange) + self.assertEqual('12 mail2.unit.tests.', a.values[1].rdata_text) + + def test_mx_value(self): + a = MxValue( + {'preference': 0, 'priority': 'a', 'exchange': 'v', 'value': '1'} + ) + b = MxValue( + {'preference': 10, 'priority': 'a', 'exchange': 'v', 'value': '2'} + ) + c = MxValue( + {'preference': 0, 'priority': 'b', 'exchange': 'z', 'value': '3'} + ) + + self.assertEqual(a, a) + self.assertEqual(b, b) + self.assertEqual(c, c) + + self.assertNotEqual(a, b) + self.assertNotEqual(a, c) + self.assertNotEqual(b, a) + self.assertNotEqual(b, c) + self.assertNotEqual(c, a) + self.assertNotEqual(c, b) + + self.assertTrue(a < b) + self.assertTrue(a < c) + + self.assertTrue(b > a) + self.assertTrue(b > c) + + self.assertTrue(c > a) + self.assertTrue(c < b) + + self.assertTrue(a <= b) + self.assertTrue(a <= c) + self.assertTrue(a <= a) + self.assertTrue(a >= a) + + self.assertTrue(b >= a) + self.assertTrue(b >= c) + self.assertTrue(b >= b) + self.assertTrue(b <= b) + + self.assertTrue(c >= a) + self.assertTrue(c <= b) + self.assertTrue(c >= c) + self.assertTrue(c <= c) + + self.assertEqual(a.__hash__(), a.__hash__()) + self.assertNotEqual(a.__hash__(), b.__hash__()) + + def test_validation(self): + # doesn't blow up + Record.new( + self.zone, + '', + { + 'type': 'MX', + 'ttl': 600, + 'value': {'preference': 10, 'exchange': 'foo.bar.com.'}, + }, + ) + + # missing preference + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'MX', + 'ttl': 600, + 'value': {'exchange': 'foo.bar.com.'}, + }, + ) + self.assertEqual(['missing preference'], ctx.exception.reasons) + + # invalid preference + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'MX', + 'ttl': 600, + 'value': {'preference': 'nope', 'exchange': 'foo.bar.com.'}, + }, + ) + self.assertEqual(['invalid preference "nope"'], ctx.exception.reasons) + + # missing exchange + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + {'type': 'MX', 'ttl': 600, 'value': {'preference': 10}}, + ) + self.assertEqual(['missing exchange'], ctx.exception.reasons) + + # missing trailing . + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'MX', + 'ttl': 600, + 'value': {'preference': 10, 'exchange': 'foo.bar.com'}, + }, + ) + self.assertEqual( + ['MX value "foo.bar.com" missing trailing .'], ctx.exception.reasons + ) + + # exchange must be a valid FQDN + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'MX', + 'ttl': 600, + 'value': {'preference': 10, 'exchange': '100 foo.bar.com.'}, + }, + ) + self.assertEqual( + ['Invalid MX exchange "100 foo.bar.com." is not a valid FQDN.'], + ctx.exception.reasons, + ) + + # if exchange doesn't exist value can not be None/falsey + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'MX', + 'ttl': 600, + 'value': {'preference': 10, 'value': ''}, + }, + ) + self.assertEqual(['missing exchange'], ctx.exception.reasons) + + # exchange can be a single `.` + record = Record.new( + self.zone, + '', + { + 'type': 'MX', + 'ttl': 600, + 'value': {'preference': 0, 'exchange': '.'}, + }, + ) + self.assertEqual('.', record.values[0].exchange) diff --git a/tests/test_octodns_record_naptr.py b/tests/test_octodns_record_naptr.py new file mode 100644 index 0000000..788a1c2 --- /dev/null +++ b/tests/test_octodns_record_naptr.py @@ -0,0 +1,438 @@ +# +# +# + +from unittest import TestCase + +from octodns.record import Record +from octodns.record.naptr import NaptrRecord, NaptrValue +from octodns.record.exception import ValidationError +from octodns.record.rr import RrParseError +from octodns.zone import Zone + +from helpers import SimpleProvider + + +class TestRecordNaptr(TestCase): + zone = Zone('unit.tests.', []) + + def test_naptr(self): + a_values = [ + NaptrValue( + { + 'order': 10, + 'preference': 11, + 'flags': 'X', + 'service': 'Y', + 'regexp': 'Z', + 'replacement': '.', + } + ), + NaptrValue( + { + 'order': 20, + 'preference': 21, + 'flags': 'A', + 'service': 'B', + 'regexp': 'C', + 'replacement': 'foo.com', + } + ), + ] + a_data = {'ttl': 30, 'values': a_values} + a = NaptrRecord(self.zone, 'a', a_data) + self.assertEqual('a', a.name) + self.assertEqual('a.unit.tests.', a.fqdn) + self.assertEqual(30, a.ttl) + for i in (0, 1): + for k in a_values[0].keys(): + self.assertEqual(a_values[i][k], getattr(a.values[i], k)) + self.assertEqual(a_data, a.data) + + b_value = NaptrValue( + { + 'order': 30, + 'preference': 31, + 'flags': 'M', + 'service': 'N', + 'regexp': 'O', + 'replacement': 'x', + } + ) + b_data = {'ttl': 30, 'value': b_value} + b = NaptrRecord(self.zone, 'b', b_data) + for k in a_values[0].keys(): + self.assertEqual(b_value[k], getattr(b.values[0], k)) + self.assertEqual(b_data, b.data) + + target = SimpleProvider() + # No changes with self + self.assertFalse(a.changes(a, target)) + # Diff in priority causes change + other = NaptrRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].order = 22 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in replacement causes change + other.values[0].order = a.values[0].order + other.values[0].replacement = 'smtpX' + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + + # full sorting + # equivalent + b_naptr_value = b.values[0] + self.assertTrue(b_naptr_value == b_naptr_value) + self.assertFalse(b_naptr_value != b_naptr_value) + self.assertTrue(b_naptr_value <= b_naptr_value) + self.assertTrue(b_naptr_value >= b_naptr_value) + # by order + self.assertTrue( + b_naptr_value + > NaptrValue( + { + 'order': 10, + 'preference': 31, + 'flags': 'M', + 'service': 'N', + 'regexp': 'O', + 'replacement': 'x', + } + ) + ) + self.assertTrue( + b_naptr_value + < NaptrValue( + { + 'order': 40, + 'preference': 31, + 'flags': 'M', + 'service': 'N', + 'regexp': 'O', + 'replacement': 'x', + } + ) + ) + # by preference + self.assertTrue( + b_naptr_value + > NaptrValue( + { + 'order': 30, + 'preference': 10, + 'flags': 'M', + 'service': 'N', + 'regexp': 'O', + 'replacement': 'x', + } + ) + ) + self.assertTrue( + b_naptr_value + < NaptrValue( + { + 'order': 30, + 'preference': 40, + 'flags': 'M', + 'service': 'N', + 'regexp': 'O', + 'replacement': 'x', + } + ) + ) + # by flags + self.assertTrue( + b_naptr_value + > NaptrValue( + { + 'order': 30, + 'preference': 31, + 'flags': 'A', + 'service': 'N', + 'regexp': 'O', + 'replacement': 'x', + } + ) + ) + self.assertTrue( + b_naptr_value + < NaptrValue( + { + 'order': 30, + 'preference': 31, + 'flags': 'Z', + 'service': 'N', + 'regexp': 'O', + 'replacement': 'x', + } + ) + ) + # by service + self.assertTrue( + b_naptr_value + > NaptrValue( + { + 'order': 30, + 'preference': 31, + 'flags': 'M', + 'service': 'A', + 'regexp': 'O', + 'replacement': 'x', + } + ) + ) + self.assertTrue( + b_naptr_value + < NaptrValue( + { + 'order': 30, + 'preference': 31, + 'flags': 'M', + 'service': 'Z', + 'regexp': 'O', + 'replacement': 'x', + } + ) + ) + # by regexp + self.assertTrue( + b_naptr_value + > NaptrValue( + { + 'order': 30, + 'preference': 31, + 'flags': 'M', + 'service': 'N', + 'regexp': 'A', + 'replacement': 'x', + } + ) + ) + self.assertTrue( + b_naptr_value + < NaptrValue( + { + 'order': 30, + 'preference': 31, + 'flags': 'M', + 'service': 'N', + 'regexp': 'Z', + 'replacement': 'x', + } + ) + ) + # by replacement + self.assertTrue( + b_naptr_value + > NaptrValue( + { + 'order': 30, + 'preference': 31, + 'flags': 'M', + 'service': 'N', + 'regexp': 'O', + 'replacement': 'a', + } + ) + ) + self.assertTrue( + b_naptr_value + < NaptrValue( + { + 'order': 30, + 'preference': 31, + 'flags': 'M', + 'service': 'N', + 'regexp': 'O', + 'replacement': 'z', + } + ) + ) + + # __repr__ doesn't blow up + a.__repr__() + + # Hash + v = NaptrValue( + { + 'order': 30, + 'preference': 31, + 'flags': 'M', + 'service': 'N', + 'regexp': 'O', + 'replacement': 'z', + } + ) + o = NaptrValue( + { + 'order': 30, + 'preference': 32, + 'flags': 'M', + 'service': 'N', + 'regexp': 'O', + 'replacement': 'z', + } + ) + values = set() + values.add(v) + self.assertTrue(v in values) + self.assertFalse(o in values) + values.add(o) + self.assertTrue(o in values) + + self.assertEqual(30, o.order) + o.order = o.order + 1 + self.assertEqual(31, o.order) + + self.assertEqual(32, o.preference) + o.preference = o.preference + 1 + self.assertEqual(33, o.preference) + + self.assertEqual('M', o.flags) + o.flags = 'P' + self.assertEqual('P', o.flags) + + self.assertEqual('N', o.service) + o.service = 'Q' + self.assertEqual('Q', o.service) + + self.assertEqual('O', o.regexp) + o.regexp = 'R' + self.assertEqual('R', o.regexp) + + self.assertEqual('z', o.replacement) + o.replacement = '1' + self.assertEqual('1', o.replacement) + + def test_naptr_value_rdata_text(self): + # things with the wrong number of words won't parse + for v in ( + '', + 'one', + 'one two', + 'one two three', + 'one two three four', + 'one two three four five', + 'one two three four five six seven', + ): + with self.assertRaises(RrParseError): + NaptrValue.parse_rdata_text(v) + + # we don't care if the types of things are correct when parsing rr text + self.assertEqual( + { + 'order': 'one', + 'preference': 'two', + 'flags': 'three', + 'service': 'four', + 'regexp': 'five', + 'replacement': 'six', + }, + NaptrValue.parse_rdata_text('one two three four five six'), + ) + + # order and preference will be converted to int's when possible + self.assertEqual( + { + 'order': 1, + 'preference': 2, + 'flags': 'three', + 'service': 'four', + 'regexp': 'five', + 'replacement': 'six', + }, + NaptrValue.parse_rdata_text('1 2 three four five six'), + ) + + # make sure that the cstor is using parse_rdata_text + zone = Zone('unit.tests.', []) + a = NaptrRecord( + zone, + 'naptr', + { + 'ttl': 32, + 'value': { + 'order': 1, + 'preference': 2, + 'flags': 'S', + 'service': 'service', + 'regexp': 'regexp', + 'replacement': 'replacement', + }, + }, + ) + self.assertEqual(1, a.values[0].order) + self.assertEqual(2, a.values[0].preference) + self.assertEqual('S', a.values[0].flags) + self.assertEqual('service', a.values[0].service) + self.assertEqual('regexp', a.values[0].regexp) + self.assertEqual('replacement', a.values[0].replacement) + s = '1 2 S service regexp replacement' + self.assertEqual(s, a.values[0].rdata_text) + + def test_validation(self): + # doesn't blow up + Record.new( + self.zone, + '', + { + 'type': 'NAPTR', + 'ttl': 600, + 'value': { + 'order': 10, + 'preference': 20, + 'flags': 'S', + 'service': 'srv', + 'regexp': '.*', + 'replacement': '.', + }, + }, + ) + + # missing X priority + value = { + 'order': 10, + 'preference': 20, + 'flags': 'S', + 'service': 'srv', + 'regexp': '.*', + 'replacement': '.', + } + for k in ( + 'order', + 'preference', + 'flags', + 'service', + 'regexp', + 'replacement', + ): + v = dict(value) + del v[k] + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, '', {'type': 'NAPTR', 'ttl': 600, 'value': v} + ) + self.assertEqual([f'missing {k}'], ctx.exception.reasons) + + # non-int order + v = dict(value) + v['order'] = 'boo' + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', {'type': 'NAPTR', 'ttl': 600, 'value': v}) + self.assertEqual(['invalid order "boo"'], ctx.exception.reasons) + + # non-int preference + v = dict(value) + v['preference'] = 'who' + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', {'type': 'NAPTR', 'ttl': 600, 'value': v}) + self.assertEqual(['invalid preference "who"'], ctx.exception.reasons) + + # unrecognized flags + v = dict(value) + v['flags'] = 'X' + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', {'type': 'NAPTR', 'ttl': 600, 'value': v}) + self.assertEqual(['unrecognized flags "X"'], ctx.exception.reasons) diff --git a/tests/test_octodns_record_ns.py b/tests/test_octodns_record_ns.py new file mode 100644 index 0000000..1b5cd2f --- /dev/null +++ b/tests/test_octodns_record_ns.py @@ -0,0 +1,83 @@ +# +# +# + +from unittest import TestCase + +from octodns.record import Record +from octodns.record.ns import NsRecord, NsValue +from octodns.record.exception import ValidationError +from octodns.zone import Zone + + +class TestRecordNs(TestCase): + zone = Zone('unit.tests.', []) + + def test_ns(self): + a_values = ['5.6.7.8.', '6.7.8.9.', '7.8.9.0.'] + a_data = {'ttl': 30, 'values': a_values} + a = NsRecord(self.zone, 'a', a_data) + self.assertEqual('a', a.name) + self.assertEqual('a.unit.tests.', a.fqdn) + self.assertEqual(30, a.ttl) + self.assertEqual(a_values, a.values) + self.assertEqual(a_data, a.data) + + b_value = '9.8.7.6.' + b_data = {'ttl': 30, 'value': b_value} + b = NsRecord(self.zone, 'b', b_data) + self.assertEqual([b_value], b.values) + self.assertEqual(b_data, b.data) + + def test_ns_value_rdata_text(self): + # anything goes, we're a noop + for s in ( + None, + '', + 'word', + 42, + 42.43, + '1.2.3', + 'some.words.that.here', + '1.2.word.4', + '1.2.3.4', + ): + self.assertEqual(s, NsValue.parse_rdata_text(s)) + + zone = Zone('unit.tests.', []) + a = NsRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) + self.assertEqual('some.target.', a.values[0].rdata_text) + + def test_validation(self): + # doesn't blow up + Record.new( + self.zone, + '', + {'type': 'NS', 'ttl': 600, 'values': ['foo.bar.com.', '1.2.3.4.']}, + ) + + # missing value + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', {'type': 'NS', 'ttl': 600}) + self.assertEqual(['missing value(s)'], ctx.exception.reasons) + + # no trailing . + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, '', {'type': 'NS', 'ttl': 600, 'value': 'foo.bar'} + ) + self.assertEqual( + ['NS value "foo.bar" missing trailing .'], ctx.exception.reasons + ) + + # exchange must be a valid FQDN + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + {'type': 'NS', 'ttl': 600, 'value': '100 foo.bar.com.'}, + ) + self.assertEqual( + ['Invalid NS value "100 foo.bar.com." is not a valid FQDN.'], + ctx.exception.reasons, + ) diff --git a/tests/test_octodns_record_ptr.py b/tests/test_octodns_record_ptr.py new file mode 100644 index 0000000..eb515ff --- /dev/null +++ b/tests/test_octodns_record_ptr.py @@ -0,0 +1,89 @@ +# +# +# + +from unittest import TestCase + +from octodns.record import Record +from octodns.record.ptr import PtrRecord, PtrValue +from octodns.record.exception import ValidationError +from octodns.zone import Zone + + +class TestRecordPtr(TestCase): + zone = Zone('unit.tests.', []) + + def test_ptr_lowering_value(self): + upper_record = PtrRecord( + self.zone, + 'PtrUppwerValue', + {'ttl': 30, 'type': 'PTR', 'value': 'GITHUB.COM.'}, + ) + lower_record = PtrRecord( + self.zone, + 'PtrLowerValue', + {'ttl': 30, 'type': 'PTR', 'value': 'github.com.'}, + ) + self.assertEqual(upper_record.value, lower_record.value) + + def test_ptr(self): + # doesn't blow up (name & zone here don't make any sense, but not + # important) + Record.new( + self.zone, '', {'type': 'PTR', 'ttl': 600, 'value': 'foo.bar.com.'} + ) + + # missing value + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', {'type': 'PTR', 'ttl': 600}) + self.assertEqual(['missing value(s)'], ctx.exception.reasons) + + # empty value + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', {'type': 'PTR', 'ttl': 600, 'value': ''}) + self.assertEqual(['missing value(s)'], ctx.exception.reasons) + + # not a valid FQDN + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, '', {'type': 'PTR', 'ttl': 600, 'value': '_.'} + ) + self.assertEqual( + ['Invalid PTR value "_." is not a valid FQDN.'], + ctx.exception.reasons, + ) + + # no trailing . + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, '', {'type': 'PTR', 'ttl': 600, 'value': 'foo.bar'} + ) + self.assertEqual( + ['PTR value "foo.bar" missing trailing .'], ctx.exception.reasons + ) + + def test_ptr_rdata_text(self): + + # anything goes, we're a noop + for s in ( + None, + '', + 'word', + 42, + 42.43, + '1.2.3', + 'some.words.that.here', + '1.2.word.4', + '1.2.3.4', + ): + self.assertEqual(s, PtrValue.parse_rdata_text(s)) + + zone = Zone('unit.tests.', []) + a = PtrRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) + self.assertEqual('some.target.', a.values[0].rdata_text) + + a = PtrRecord( + zone, 'a', {'ttl': 42, 'values': ['some.target.', 'second.target.']} + ) + self.assertEqual('second.target.', a.values[0].rdata_text) + self.assertEqual('some.target.', a.values[1].rdata_text) diff --git a/tests/test_octodns_record_spf.py b/tests/test_octodns_record_spf.py new file mode 100644 index 0000000..360de78 --- /dev/null +++ b/tests/test_octodns_record_spf.py @@ -0,0 +1,71 @@ +# +# +# + +from unittest import TestCase + +from octodns.record import Record +from octodns.record.spf import SpfRecord +from octodns.record.exception import ValidationError +from octodns.zone import Zone + + +class TestRecordSpf(TestCase): + zone = Zone('unit.tests.', []) + + def assertMultipleValues(self, _type, a_values, b_value): + a_data = {'ttl': 30, 'values': a_values} + a = _type(self.zone, 'a', a_data) + self.assertEqual('a', a.name) + self.assertEqual('a.unit.tests.', a.fqdn) + self.assertEqual(30, a.ttl) + self.assertEqual(a_values, a.values) + self.assertEqual(a_data, a.data) + + b_data = {'ttl': 30, 'value': b_value} + b = _type(self.zone, 'b', b_data) + self.assertEqual([b_value], b.values) + self.assertEqual(b_data, b.data) + + def test_spf(self): + a_values = ['spf1 -all', 'spf1 -hrm'] + b_value = 'spf1 -other' + self.assertMultipleValues(SpfRecord, a_values, b_value) + + def test_validation(self): + # doesn't blow up (name & zone here don't make any sense, but not + # important) + Record.new( + self.zone, + '', + { + 'type': 'SPF', + 'ttl': 600, + 'values': [ + 'v=spf1 ip4:192.168.0.1/16-all', + 'v=spf1 ip4:10.1.2.1/24-all', + 'this has some\\; semi-colons\\; in it', + ], + }, + ) + + # missing value + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', {'type': 'SPF', 'ttl': 600}) + self.assertEqual(['missing value(s)'], ctx.exception.reasons) + + # missing escapes + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'SPF', + 'ttl': 600, + 'value': 'this has some; semi-colons\\; in it', + }, + ) + self.assertEqual( + ['unescaped ; in "this has some; semi-colons\\; in it"'], + ctx.exception.reasons, + ) diff --git a/tests/test_octodns_record_srv.py b/tests/test_octodns_record_srv.py new file mode 100644 index 0000000..f007bfb --- /dev/null +++ b/tests/test_octodns_record_srv.py @@ -0,0 +1,432 @@ +# +# +# + +from unittest import TestCase + +from octodns.record import Record +from octodns.record.srv import SrvRecord, SrvValue +from octodns.record.exception import ValidationError +from octodns.record.rr import RrParseError +from octodns.zone import Zone + +from helpers import SimpleProvider + + +class TestRecordSrv(TestCase): + zone = Zone('unit.tests.', []) + + def test_srv(self): + a_values = [ + SrvValue( + {'priority': 10, 'weight': 11, 'port': 12, 'target': 'server1'} + ), + SrvValue( + {'priority': 20, 'weight': 21, 'port': 22, 'target': 'server2'} + ), + ] + a_data = {'ttl': 30, 'values': a_values} + a = SrvRecord(self.zone, '_a._tcp', a_data) + self.assertEqual('_a._tcp', a.name) + self.assertEqual('_a._tcp.unit.tests.', a.fqdn) + self.assertEqual(30, a.ttl) + self.assertEqual(a_values[0]['priority'], a.values[0].priority) + self.assertEqual(a_values[0]['weight'], a.values[0].weight) + self.assertEqual(a_values[0]['port'], a.values[0].port) + self.assertEqual(a_values[0]['target'], a.values[0].target) + self.assertEqual(a_data, a.data) + + b_value = SrvValue( + {'priority': 30, 'weight': 31, 'port': 32, 'target': 'server3'} + ) + b_data = {'ttl': 30, 'value': b_value} + b = SrvRecord(self.zone, '_b._tcp', b_data) + self.assertEqual(b_value['priority'], b.values[0].priority) + self.assertEqual(b_value['weight'], b.values[0].weight) + self.assertEqual(b_value['port'], b.values[0].port) + self.assertEqual(b_value['target'], b.values[0].target) + self.assertEqual(b_data, b.data) + + target = SimpleProvider() + # No changes with self + self.assertFalse(a.changes(a, target)) + # Diff in priority causes change + other = SrvRecord( + self.zone, '_a._icmp', {'ttl': 30, 'values': a_values} + ) + other.values[0].priority = 22 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in weight causes change + other.values[0].priority = a.values[0].priority + other.values[0].weight = 33 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in port causes change + other.values[0].weight = a.values[0].weight + other.values[0].port = 44 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in target causes change + other.values[0].port = a.values[0].port + other.values[0].target = 'serverX' + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + + # __repr__ doesn't blow up + a.__repr__() + + def test_srv_value_rdata_text(self): + + # empty string won't parse + with self.assertRaises(RrParseError): + SrvValue.parse_rdata_text('') + + # single word won't parse + with self.assertRaises(RrParseError): + SrvValue.parse_rdata_text('nope') + + # 2nd word won't parse + with self.assertRaises(RrParseError): + SrvValue.parse_rdata_text('1 2') + + # 3rd word won't parse + with self.assertRaises(RrParseError): + SrvValue.parse_rdata_text('1 2 3') + + # 5th word won't parse + with self.assertRaises(RrParseError): + SrvValue.parse_rdata_text('1 2 3 4 5') + + # priority weight and port not ints + self.assertEqual( + { + 'priority': 'one', + 'weight': 'two', + 'port': 'three', + 'target': 'srv.unit.tests.', + }, + SrvValue.parse_rdata_text('one two three srv.unit.tests.'), + ) + + # valid + self.assertEqual( + { + 'priority': 1, + 'weight': 2, + 'port': 3, + 'target': 'srv.unit.tests.', + }, + SrvValue.parse_rdata_text('1 2 3 srv.unit.tests.'), + ) + + zone = Zone('unit.tests.', []) + a = SrvRecord( + zone, + '_srv._tcp', + { + 'ttl': 32, + 'value': { + 'priority': 1, + 'weight': 2, + 'port': 3, + 'target': 'srv.unit.tests.', + }, + }, + ) + self.assertEqual(1, a.values[0].priority) + self.assertEqual(2, a.values[0].weight) + self.assertEqual(3, a.values[0].port) + self.assertEqual('srv.unit.tests.', a.values[0].target) + + def test_srv_value(self): + a = SrvValue({'priority': 0, 'weight': 0, 'port': 0, 'target': 'foo.'}) + b = SrvValue({'priority': 1, 'weight': 0, 'port': 0, 'target': 'foo.'}) + c = SrvValue({'priority': 0, 'weight': 2, 'port': 0, 'target': 'foo.'}) + d = SrvValue({'priority': 0, 'weight': 0, 'port': 3, 'target': 'foo.'}) + e = SrvValue({'priority': 0, 'weight': 0, 'port': 0, 'target': 'mmm.'}) + + self.assertEqual(a, a) + self.assertEqual(b, b) + self.assertEqual(c, c) + self.assertEqual(d, d) + self.assertEqual(e, e) + + self.assertNotEqual(a, b) + self.assertNotEqual(a, c) + self.assertNotEqual(a, d) + self.assertNotEqual(a, e) + self.assertNotEqual(b, a) + self.assertNotEqual(b, c) + self.assertNotEqual(b, d) + self.assertNotEqual(b, e) + self.assertNotEqual(c, a) + self.assertNotEqual(c, b) + self.assertNotEqual(c, d) + self.assertNotEqual(c, e) + self.assertNotEqual(d, a) + self.assertNotEqual(d, b) + self.assertNotEqual(d, c) + self.assertNotEqual(d, e) + self.assertNotEqual(e, a) + self.assertNotEqual(e, b) + self.assertNotEqual(e, c) + self.assertNotEqual(e, d) + + self.assertTrue(a < b) + self.assertTrue(a < c) + + self.assertTrue(b > a) + self.assertTrue(b > c) + + self.assertTrue(c > a) + self.assertTrue(c < b) + + self.assertTrue(a <= b) + self.assertTrue(a <= c) + self.assertTrue(a <= a) + self.assertTrue(a >= a) + + self.assertTrue(b >= a) + self.assertTrue(b >= c) + self.assertTrue(b >= b) + self.assertTrue(b <= b) + + self.assertTrue(c >= a) + self.assertTrue(c <= b) + self.assertTrue(c >= c) + self.assertTrue(c <= c) + + # Hash + values = set() + values.add(a) + self.assertTrue(a in values) + self.assertFalse(b in values) + values.add(b) + self.assertTrue(b in values) + + def test_valiation(self): + # doesn't blow up + Record.new( + self.zone, + '_srv._tcp', + { + 'type': 'SRV', + 'ttl': 600, + 'value': { + 'priority': 1, + 'weight': 2, + 'port': 3, + 'target': 'foo.bar.baz.', + }, + }, + ) + + # permit wildcard entries + Record.new( + self.zone, + '*._tcp', + { + 'type': 'SRV', + 'ttl': 600, + 'value': { + 'priority': 1, + 'weight': 2, + 'port': 3, + 'target': 'food.bar.baz.', + }, + }, + ) + + # invalid name + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + 'neup', + { + 'type': 'SRV', + 'ttl': 600, + 'value': { + 'priority': 1, + 'weight': 2, + 'port': 3, + 'target': 'foo.bar.baz.', + }, + }, + ) + self.assertEqual(['invalid name for SRV record'], ctx.exception.reasons) + + # missing priority + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '_srv._tcp', + { + 'type': 'SRV', + 'ttl': 600, + 'value': {'weight': 2, 'port': 3, 'target': 'foo.bar.baz.'}, + }, + ) + self.assertEqual(['missing priority'], ctx.exception.reasons) + + # invalid priority + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '_srv._tcp', + { + 'type': 'SRV', + 'ttl': 600, + 'value': { + 'priority': 'foo', + 'weight': 2, + 'port': 3, + 'target': 'foo.bar.baz.', + }, + }, + ) + self.assertEqual(['invalid priority "foo"'], ctx.exception.reasons) + + # missing weight + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '_srv._tcp', + { + 'type': 'SRV', + 'ttl': 600, + 'value': { + 'priority': 1, + 'port': 3, + 'target': 'foo.bar.baz.', + }, + }, + ) + self.assertEqual(['missing weight'], ctx.exception.reasons) + # invalid weight + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '_srv._tcp', + { + 'type': 'SRV', + 'ttl': 600, + 'value': { + 'priority': 1, + 'weight': 'foo', + 'port': 3, + 'target': 'foo.bar.baz.', + }, + }, + ) + self.assertEqual(['invalid weight "foo"'], ctx.exception.reasons) + + # missing port + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '_srv._tcp', + { + 'type': 'SRV', + 'ttl': 600, + 'value': { + 'priority': 1, + 'weight': 2, + 'target': 'foo.bar.baz.', + }, + }, + ) + self.assertEqual(['missing port'], ctx.exception.reasons) + # invalid port + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '_srv._tcp', + { + 'type': 'SRV', + 'ttl': 600, + 'value': { + 'priority': 1, + 'weight': 2, + 'port': 'foo', + 'target': 'foo.bar.baz.', + }, + }, + ) + self.assertEqual(['invalid port "foo"'], ctx.exception.reasons) + + # missing target + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '_srv._tcp', + { + 'type': 'SRV', + 'ttl': 600, + 'value': {'priority': 1, 'weight': 2, 'port': 3}, + }, + ) + self.assertEqual(['missing target'], ctx.exception.reasons) + # invalid target + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '_srv._tcp', + { + 'type': 'SRV', + 'ttl': 600, + 'value': { + 'priority': 1, + 'weight': 2, + 'port': 3, + 'target': 'foo.bar.baz', + }, + }, + ) + self.assertEqual( + ['SRV value "foo.bar.baz" missing trailing .'], + ctx.exception.reasons, + ) + + # falsey target + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '_srv._tcp', + { + 'type': 'SRV', + 'ttl': 600, + 'value': { + 'priority': 1, + 'weight': 2, + 'port': 3, + 'target': '', + }, + }, + ) + self.assertEqual(['missing target'], ctx.exception.reasons) + + # target must be a valid FQDN + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '_srv._tcp', + { + 'type': 'SRV', + 'ttl': 600, + 'value': { + 'priority': 1, + 'weight': 2, + 'port': 3, + 'target': '100 foo.bar.com.', + }, + }, + ) + self.assertEqual( + ['Invalid SRV target "100 foo.bar.com." is not a valid FQDN.'], + ctx.exception.reasons, + ) diff --git a/tests/test_octodns_record_sshfp.py b/tests/test_octodns_record_sshfp.py new file mode 100644 index 0000000..ea1a75f --- /dev/null +++ b/tests/test_octodns_record_sshfp.py @@ -0,0 +1,330 @@ +# +# +# + +from unittest import TestCase + +from octodns.record.base import Record +from octodns.record.exception import ValidationError +from octodns.record.sshfp import SshfpRecord, SshfpValue +from octodns.record.rr import RrParseError +from octodns.zone import Zone + +from helpers import SimpleProvider + + +class TestRecordSshfp(TestCase): + zone = Zone('unit.tests.', []) + + def test_sshfp(self): + a_values = [ + SshfpValue( + { + 'algorithm': 10, + 'fingerprint_type': 11, + 'fingerprint': 'abc123', + } + ), + SshfpValue( + { + 'algorithm': 20, + 'fingerprint_type': 21, + 'fingerprint': 'def456', + } + ), + ] + a_data = {'ttl': 30, 'values': a_values} + a = SshfpRecord(self.zone, 'a', a_data) + self.assertEqual('a', a.name) + self.assertEqual('a.unit.tests.', a.fqdn) + self.assertEqual(30, a.ttl) + self.assertEqual(a_values[0]['algorithm'], a.values[0].algorithm) + self.assertEqual( + a_values[0]['fingerprint_type'], a.values[0].fingerprint_type + ) + self.assertEqual(a_values[0]['fingerprint'], a.values[0].fingerprint) + self.assertEqual(a_data, a.data) + + b_value = SshfpValue( + {'algorithm': 30, 'fingerprint_type': 31, 'fingerprint': 'ghi789'} + ) + b_data = {'ttl': 30, 'value': b_value} + b = SshfpRecord(self.zone, 'b', b_data) + self.assertEqual(b_value['algorithm'], b.values[0].algorithm) + self.assertEqual( + b_value['fingerprint_type'], b.values[0].fingerprint_type + ) + self.assertEqual(b_value['fingerprint'], b.values[0].fingerprint) + self.assertEqual(b_data, b.data) + + target = SimpleProvider() + # No changes with self + self.assertFalse(a.changes(a, target)) + # Diff in algorithm causes change + other = SshfpRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].algorithm = 22 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in fingerprint_type causes change + other = SshfpRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].algorithm = a.values[0].algorithm + other.values[0].fingerprint_type = 22 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in fingerprint causes change + other = SshfpRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].fingerprint_type = a.values[0].fingerprint_type + other.values[0].fingerprint = 22 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + + # __repr__ doesn't blow up + a.__repr__() + + def test_sshfp_value_rdata_text(self): + + # empty string won't parse + with self.assertRaises(RrParseError): + SshfpValue.parse_rdata_text('') + + # single word won't parse + with self.assertRaises(RrParseError): + SshfpValue.parse_rdata_text('nope') + + # 3rd word won't parse + with self.assertRaises(RrParseError): + SshfpValue.parse_rdata_text('0 1 00479b27 another') + + # algorithm and fingerprint_type not ints + self.assertEqual( + { + 'algorithm': 'one', + 'fingerprint_type': 'two', + 'fingerprint': '00479b27', + }, + SshfpValue.parse_rdata_text('one two 00479b27'), + ) + + # valid + self.assertEqual( + {'algorithm': 1, 'fingerprint_type': 2, 'fingerprint': '00479b27'}, + SshfpValue.parse_rdata_text('1 2 00479b27'), + ) + + zone = Zone('unit.tests.', []) + a = SshfpRecord( + zone, + 'sshfp', + { + 'ttl': 32, + 'value': { + 'algorithm': 1, + 'fingerprint_type': 2, + 'fingerprint': '00479b27', + }, + }, + ) + self.assertEqual(1, a.values[0].algorithm) + self.assertEqual(2, a.values[0].fingerprint_type) + self.assertEqual('00479b27', a.values[0].fingerprint) + self.assertEqual('1 2 00479b27', a.values[0].rdata_text) + + def test_sshfp_value(self): + a = SshfpValue( + {'algorithm': 0, 'fingerprint_type': 0, 'fingerprint': 'abcd'} + ) + b = SshfpValue( + {'algorithm': 1, 'fingerprint_type': 0, 'fingerprint': 'abcd'} + ) + c = SshfpValue( + {'algorithm': 0, 'fingerprint_type': 1, 'fingerprint': 'abcd'} + ) + d = SshfpValue( + {'algorithm': 0, 'fingerprint_type': 0, 'fingerprint': 'bcde'} + ) + + self.assertEqual(a, a) + self.assertEqual(b, b) + self.assertEqual(c, c) + self.assertEqual(d, d) + + self.assertNotEqual(a, b) + self.assertNotEqual(a, c) + self.assertNotEqual(a, d) + self.assertNotEqual(b, a) + self.assertNotEqual(b, c) + self.assertNotEqual(b, d) + self.assertNotEqual(c, a) + self.assertNotEqual(c, b) + self.assertNotEqual(c, d) + self.assertNotEqual(d, a) + self.assertNotEqual(d, b) + self.assertNotEqual(d, c) + + self.assertTrue(a < b) + self.assertTrue(a < c) + + self.assertTrue(b > a) + self.assertTrue(b > c) + + self.assertTrue(c > a) + self.assertTrue(c < b) + + self.assertTrue(a <= b) + self.assertTrue(a <= c) + self.assertTrue(a <= a) + self.assertTrue(a >= a) + + self.assertTrue(b >= a) + self.assertTrue(b >= c) + self.assertTrue(b >= b) + self.assertTrue(b <= b) + + self.assertTrue(c >= a) + self.assertTrue(c <= b) + self.assertTrue(c >= c) + self.assertTrue(c <= c) + + # Hash + values = set() + values.add(a) + self.assertTrue(a in values) + self.assertFalse(b in values) + values.add(b) + self.assertTrue(b in values) + + def test_validation(self): + # doesn't blow up + Record.new( + self.zone, + '', + { + 'type': 'SSHFP', + 'ttl': 600, + 'value': { + 'algorithm': 1, + 'fingerprint_type': 1, + 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', + }, + }, + ) + + # missing algorithm + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'SSHFP', + 'ttl': 600, + 'value': { + 'fingerprint_type': 1, + 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', + }, + }, + ) + self.assertEqual(['missing algorithm'], ctx.exception.reasons) + + # invalid algorithm + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'SSHFP', + 'ttl': 600, + 'value': { + 'algorithm': 'nope', + 'fingerprint_type': 2, + 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', + }, + }, + ) + self.assertEqual(['invalid algorithm "nope"'], ctx.exception.reasons) + + # unrecognized algorithm + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'SSHFP', + 'ttl': 600, + 'value': { + 'algorithm': 42, + 'fingerprint_type': 1, + 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', + }, + }, + ) + self.assertEqual(['unrecognized algorithm "42"'], ctx.exception.reasons) + + # missing fingerprint_type + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'SSHFP', + 'ttl': 600, + 'value': { + 'algorithm': 2, + 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', + }, + }, + ) + self.assertEqual(['missing fingerprint_type'], ctx.exception.reasons) + + # invalid fingerprint_type + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'SSHFP', + 'ttl': 600, + 'value': { + 'algorithm': 3, + 'fingerprint_type': 'yeeah', + 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', + }, + }, + ) + self.assertEqual( + ['invalid fingerprint_type "yeeah"'], ctx.exception.reasons + ) + + # unrecognized fingerprint_type + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'SSHFP', + 'ttl': 600, + 'value': { + 'algorithm': 1, + 'fingerprint_type': 42, + 'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73', + }, + }, + ) + self.assertEqual( + ['unrecognized fingerprint_type "42"'], ctx.exception.reasons + ) + + # missing fingerprint + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'SSHFP', + 'ttl': 600, + 'value': {'algorithm': 1, 'fingerprint_type': 1}, + }, + ) + self.assertEqual(['missing fingerprint'], ctx.exception.reasons) diff --git a/tests/test_octodns_record_target.py b/tests/test_octodns_record_target.py new file mode 100644 index 0000000..728c349 --- /dev/null +++ b/tests/test_octodns_record_target.py @@ -0,0 +1,31 @@ +# +# +# + +from unittest import TestCase + +from octodns.record.alias import AliasRecord +from octodns.record.target import _TargetValue +from octodns.zone import Zone + + +class TestRecordTarget(TestCase): + def test_target_rdata_text(self): + + # anything goes, we're a noop + for s in ( + None, + '', + 'word', + 42, + 42.43, + '1.2.3', + 'some.words.that.here', + '1.2.word.4', + '1.2.3.4', + ): + self.assertEqual(s, _TargetValue.parse_rdata_text(s)) + + zone = Zone('unit.tests.', []) + a = AliasRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) + self.assertEqual('some.target.', a.value.rdata_text) diff --git a/tests/test_octodns_record_tlsa.py b/tests/test_octodns_record_tlsa.py new file mode 100644 index 0000000..ef5d7ae --- /dev/null +++ b/tests/test_octodns_record_tlsa.py @@ -0,0 +1,421 @@ +# +# +# + +from unittest import TestCase + +from octodns.record import Record +from octodns.record.tlsa import TlsaRecord, TlsaValue +from octodns.record.exception import ValidationError +from octodns.record.rr import RrParseError +from octodns.zone import Zone + +from helpers import SimpleProvider + + +class TestRecordTlsa(TestCase): + zone = Zone('unit.tests.', []) + + def test_tlsa(self): + a_values = [ + TlsaValue( + { + 'certificate_usage': 1, + 'selector': 1, + 'matching_type': 1, + 'certificate_association_data': 'ABABABABABABABABAB', + } + ), + TlsaValue( + { + 'certificate_usage': 2, + 'selector': 0, + 'matching_type': 2, + 'certificate_association_data': 'ABABABABABABABABAC', + } + ), + ] + a_data = {'ttl': 30, 'values': a_values} + a = TlsaRecord(self.zone, 'a', a_data) + self.assertEqual('a.unit.tests.', a.fqdn) + self.assertEqual('a', a.name) + self.assertEqual(30, a.ttl) + self.assertEqual( + a_values[0]['certificate_usage'], a.values[0].certificate_usage + ) + self.assertEqual(a_values[0]['selector'], a.values[0].selector) + self.assertEqual( + a_values[0]['matching_type'], a.values[0].matching_type + ) + self.assertEqual( + a_values[0]['certificate_association_data'], + a.values[0].certificate_association_data, + ) + + self.assertEqual( + a_values[1]['certificate_usage'], a.values[1].certificate_usage + ) + self.assertEqual(a_values[1]['selector'], a.values[1].selector) + self.assertEqual( + a_values[1]['matching_type'], a.values[1].matching_type + ) + self.assertEqual( + a_values[1]['certificate_association_data'], + a.values[1].certificate_association_data, + ) + self.assertEqual(a_data, a.data) + + b_value = TlsaValue( + { + 'certificate_usage': 0, + 'selector': 0, + 'matching_type': 0, + 'certificate_association_data': 'AAAAAAAAAAAAAAA', + } + ) + b_data = {'ttl': 30, 'value': b_value} + b = TlsaRecord(self.zone, 'b', b_data) + self.assertEqual( + b_value['certificate_usage'], b.values[0].certificate_usage + ) + self.assertEqual(b_value['selector'], b.values[0].selector) + self.assertEqual(b_value['matching_type'], b.values[0].matching_type) + self.assertEqual( + b_value['certificate_association_data'], + b.values[0].certificate_association_data, + ) + self.assertEqual(b_data, b.data) + + target = SimpleProvider() + # No changes with self + self.assertFalse(a.changes(a, target)) + # Diff in certificate_usage causes change + other = TlsaRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].certificate_usage = 0 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in selector causes change + other = TlsaRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].selector = 0 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in matching_type causes change + other = TlsaRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].matching_type = 0 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in certificate_association_data causes change + other = TlsaRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].certificate_association_data = 'AAAAAAAAAAAAA' + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + + # __repr__ doesn't blow up + a.__repr__() + + def test_tsla_value_rdata_text(self): + + # empty string won't parse + with self.assertRaises(RrParseError): + TlsaValue.parse_rdata_text('') + + # single word won't parse + with self.assertRaises(RrParseError): + TlsaValue.parse_rdata_text('nope') + + # 2nd word won't parse + with self.assertRaises(RrParseError): + TlsaValue.parse_rdata_text('1 2') + + # 3rd word won't parse + with self.assertRaises(RrParseError): + TlsaValue.parse_rdata_text('1 2 3') + + # 5th word won't parse + with self.assertRaises(RrParseError): + TlsaValue.parse_rdata_text('1 2 3 abcd another') + + # non-ints + self.assertEqual( + { + 'certificate_usage': 'one', + 'selector': 'two', + 'matching_type': 'three', + 'certificate_association_data': 'abcd', + }, + TlsaValue.parse_rdata_text('one two three abcd'), + ) + + # valid + self.assertEqual( + { + 'certificate_usage': 1, + 'selector': 2, + 'matching_type': 3, + 'certificate_association_data': 'abcd', + }, + TlsaValue.parse_rdata_text('1 2 3 abcd'), + ) + + zone = Zone('unit.tests.', []) + a = TlsaRecord( + zone, + 'tlsa', + { + 'ttl': 32, + 'value': { + 'certificate_usage': 2, + 'selector': 1, + 'matching_type': 0, + 'certificate_association_data': 'abcd', + }, + }, + ) + self.assertEqual(2, a.values[0].certificate_usage) + self.assertEqual(1, a.values[0].selector) + self.assertEqual(0, a.values[0].matching_type) + self.assertEqual('abcd', a.values[0].certificate_association_data) + self.assertEqual('2 1 0 abcd', a.values[0].rdata_text) + + def test_validation(self): + # doesn't blow up + Record.new( + self.zone, + '', + { + 'type': 'TLSA', + 'ttl': 600, + 'value': { + 'certificate_usage': 0, + 'selector': 0, + 'matching_type': 0, + 'certificate_association_data': 'AAAAAAAAAAAAA', + }, + }, + ) + # Multi value, second missing certificate usage + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'TLSA', + 'ttl': 600, + 'values': [ + { + 'certificate_usage': 0, + 'selector': 0, + 'matching_type': 0, + 'certificate_association_data': 'AAAAAAAAAAAAA', + }, + { + 'selector': 0, + 'matching_type': 0, + 'certificate_association_data': 'AAAAAAAAAAAAA', + }, + ], + }, + ) + self.assertEqual( + ['missing certificate_usage'], ctx.exception.reasons + ) + + # missing certificate_association_data + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'TLSA', + 'ttl': 600, + 'value': { + 'certificate_usage': 0, + 'selector': 0, + 'matching_type': 0, + }, + }, + ) + self.assertEqual( + ['missing certificate_association_data'], ctx.exception.reasons + ) + + # missing certificate_usage + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'TLSA', + 'ttl': 600, + 'value': { + 'selector': 0, + 'matching_type': 0, + 'certificate_association_data': 'AAAAAAAAAAAAA', + }, + }, + ) + self.assertEqual( + ['missing certificate_usage'], ctx.exception.reasons + ) + + # False certificate_usage + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'TLSA', + 'ttl': 600, + 'value': { + 'certificate_usage': 4, + 'selector': 0, + 'matching_type': 0, + 'certificate_association_data': 'AAAAAAAAAAAAA', + }, + }, + ) + self.assertEqual( + 'invalid certificate_usage "{value["certificate_usage"]}"', + ctx.exception.reasons, + ) + + # Invalid certificate_usage + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'TLSA', + 'ttl': 600, + 'value': { + 'certificate_usage': 'XYZ', + 'selector': 0, + 'matching_type': 0, + 'certificate_association_data': 'AAAAAAAAAAAAA', + }, + }, + ) + self.assertEqual( + 'invalid certificate_usage "{value["certificate_usage"]}"', + ctx.exception.reasons, + ) + + # missing selector + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'TLSA', + 'ttl': 600, + 'value': { + 'certificate_usage': 0, + 'matching_type': 0, + 'certificate_association_data': 'AAAAAAAAAAAAA', + }, + }, + ) + self.assertEqual(['missing selector'], ctx.exception.reasons) + + # False selector + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'TLSA', + 'ttl': 600, + 'value': { + 'certificate_usage': 0, + 'selector': 4, + 'matching_type': 0, + 'certificate_association_data': 'AAAAAAAAAAAAA', + }, + }, + ) + self.assertEqual( + 'invalid selector "{value["selector"]}"', ctx.exception.reasons + ) + + # Invalid selector + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'TLSA', + 'ttl': 600, + 'value': { + 'certificate_usage': 0, + 'selector': 'XYZ', + 'matching_type': 0, + 'certificate_association_data': 'AAAAAAAAAAAAA', + }, + }, + ) + self.assertEqual( + 'invalid selector "{value["selector"]}"', ctx.exception.reasons + ) + + # missing matching_type + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'TLSA', + 'ttl': 600, + 'value': { + 'certificate_usage': 0, + 'selector': 0, + 'certificate_association_data': 'AAAAAAAAAAAAA', + }, + }, + ) + self.assertEqual(['missing matching_type'], ctx.exception.reasons) + + # False matching_type + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'TLSA', + 'ttl': 600, + 'value': { + 'certificate_usage': 0, + 'selector': 1, + 'matching_type': 3, + 'certificate_association_data': 'AAAAAAAAAAAAA', + }, + }, + ) + self.assertEqual( + 'invalid matching_type "{value["matching_type"]}"', + ctx.exception.reasons, + ) + + # Invalid matching_type + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'TLSA', + 'ttl': 600, + 'value': { + 'certificate_usage': 0, + 'selector': 1, + 'matching_type': 'XYZ', + 'certificate_association_data': 'AAAAAAAAAAAAA', + }, + }, + ) + self.assertEqual( + 'invalid matching_type "{value["matching_type"]}"', + ctx.exception.reasons, + ) diff --git a/tests/test_octodns_record_txt.py b/tests/test_octodns_record_txt.py new file mode 100644 index 0000000..d2d4341 --- /dev/null +++ b/tests/test_octodns_record_txt.py @@ -0,0 +1,144 @@ +# +# +# + +from unittest import TestCase + +from octodns.record import Record +from octodns.record.txt import TxtRecord +from octodns.record.exception import ValidationError +from octodns.zone import Zone + + +class TestRecordTxt(TestCase): + zone = Zone('unit.tests.', []) + + def assertMultipleValues(self, _type, a_values, b_value): + a_data = {'ttl': 30, 'values': a_values} + a = _type(self.zone, 'a', a_data) + self.assertEqual('a', a.name) + self.assertEqual('a.unit.tests.', a.fqdn) + self.assertEqual(30, a.ttl) + self.assertEqual(a_values, a.values) + self.assertEqual(a_data, a.data) + + b_data = {'ttl': 30, 'value': b_value} + b = _type(self.zone, 'b', b_data) + self.assertEqual([b_value], b.values) + self.assertEqual(b_data, b.data) + + def test_txt(self): + a_values = ['a one', 'a two'] + b_value = 'b other' + self.assertMultipleValues(TxtRecord, a_values, b_value) + + def test_validation(self): + # doesn't blow up (name & zone here don't make any sense, but not + # important) + Record.new( + self.zone, + '', + { + 'type': 'TXT', + 'ttl': 600, + 'values': [ + 'hello world', + 'this has some\\; semi-colons\\; in it', + ], + }, + ) + + # missing value + with self.assertRaises(ValidationError) as ctx: + Record.new(self.zone, '', {'type': 'TXT', 'ttl': 600}) + self.assertEqual(['missing value(s)'], ctx.exception.reasons) + + # missing escapes + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'TXT', + 'ttl': 600, + 'value': 'this has some; semi-colons\\; in it', + }, + ) + self.assertEqual( + ['unescaped ; in "this has some; semi-colons\\; in it"'], + ctx.exception.reasons, + ) + + def test_long_value_chunking(self): + expected = ( + '"Lorem ipsum dolor sit amet, consectetur adipiscing ' + 'elit, sed do eiusmod tempor incididunt ut labore et dolore ' + 'magna aliqua. Ut enim ad minim veniam, quis nostrud ' + 'exercitation ullamco laboris nisi ut aliquip ex ea commodo ' + 'consequat. Duis aute irure dolor i" "n reprehenderit in ' + 'voluptate velit esse cillum dolore eu fugiat nulla pariatur. ' + 'Excepteur sint occaecat cupidatat non proident, sunt in culpa ' + 'qui officia deserunt mollit anim id est laborum."' + ) + + long_value = ( + 'Lorem ipsum dolor sit amet, consectetur adipiscing ' + 'elit, sed do eiusmod tempor incididunt ut labore et dolore ' + 'magna aliqua. Ut enim ad minim veniam, quis nostrud ' + 'exercitation ullamco laboris nisi ut aliquip ex ea commodo ' + 'consequat. Duis aute irure dolor in reprehenderit in ' + 'voluptate velit esse cillum dolore eu fugiat nulla ' + 'pariatur. Excepteur sint occaecat cupidatat non proident, ' + 'sunt in culpa qui officia deserunt mollit anim id est ' + 'laborum.' + ) + # Single string + single = Record.new( + self.zone, + '', + { + 'type': 'TXT', + 'ttl': 600, + 'values': [ + 'hello world', + long_value, + 'this has some\\; semi-colons\\; in it', + ], + }, + ) + self.assertEqual(3, len(single.values)) + self.assertEqual(3, len(single.chunked_values)) + # Note we are checking that this normalizes the chunking, not that we + # get out what we put in. + self.assertEqual(expected, single.chunked_values[0]) + + long_split_value = ( + '"Lorem ipsum dolor sit amet, consectetur ' + 'adipiscing elit, sed do eiusmod tempor incididunt ut ' + 'labore et dolore magna aliqua. Ut enim ad minim veniam, ' + 'quis nostrud exercitation ullamco laboris nisi ut aliquip ' + 'ex" " ea commodo consequat. Duis aute irure dolor in ' + 'reprehenderit in voluptate velit esse cillum dolore eu ' + 'fugiat nulla pariatur. Excepteur sint occaecat cupidatat ' + 'non proident, sunt in culpa qui officia deserunt mollit ' + 'anim id est laborum."' + ) + # Chunked + chunked = Record.new( + self.zone, + '', + { + 'type': 'TXT', + 'ttl': 600, + 'values': [ + '"hello world"', + long_split_value, + '"this has some\\; semi-colons\\; in it"', + ], + }, + ) + self.assertEqual(expected, chunked.chunked_values[0]) + # should be single values, no quoting + self.assertEqual(single.values, chunked.values) + # should be chunked values, with quoting + self.assertEqual(single.chunked_values, chunked.chunked_values) diff --git a/tests/test_octodns_record_urlfwd.py b/tests/test_octodns_record_urlfwd.py new file mode 100644 index 0000000..576efee --- /dev/null +++ b/tests/test_octodns_record_urlfwd.py @@ -0,0 +1,391 @@ +# +# +# + +from unittest import TestCase + +from octodns.record import Record +from octodns.record.urlfwd import UrlfwdRecord, UrlfwdValue +from octodns.record.exception import ValidationError +from octodns.zone import Zone + +from helpers import SimpleProvider + + +class TestRecordUrlfwd(TestCase): + zone = Zone('unit.tests.', []) + + def test_urlfwd(self): + a_values = [ + UrlfwdValue( + { + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'masking': 2, + 'query': 0, + } + ), + UrlfwdValue( + { + 'path': '/target', + 'target': 'http://target', + 'code': 302, + 'masking': 2, + 'query': 0, + } + ), + ] + a_data = {'ttl': 30, 'values': a_values} + a = UrlfwdRecord(self.zone, 'a', a_data) + self.assertEqual('a', a.name) + self.assertEqual('a.unit.tests.', a.fqdn) + self.assertEqual(30, a.ttl) + self.assertEqual(a_values[0]['path'], a.values[0].path) + self.assertEqual(a_values[0]['target'], a.values[0].target) + self.assertEqual(a_values[0]['code'], a.values[0].code) + self.assertEqual(a_values[0]['masking'], a.values[0].masking) + self.assertEqual(a_values[0]['query'], a.values[0].query) + self.assertEqual(a_values[1]['path'], a.values[1].path) + self.assertEqual(a_values[1]['target'], a.values[1].target) + self.assertEqual(a_values[1]['code'], a.values[1].code) + self.assertEqual(a_values[1]['masking'], a.values[1].masking) + self.assertEqual(a_values[1]['query'], a.values[1].query) + self.assertEqual(a_data, a.data) + + b_value = UrlfwdValue( + { + 'path': '/', + 'target': 'http://location', + 'code': 301, + 'masking': 2, + 'query': 0, + } + ) + b_data = {'ttl': 30, 'value': b_value} + b = UrlfwdRecord(self.zone, 'b', b_data) + self.assertEqual(b_value['path'], b.values[0].path) + self.assertEqual(b_value['target'], b.values[0].target) + self.assertEqual(b_value['code'], b.values[0].code) + self.assertEqual(b_value['masking'], b.values[0].masking) + self.assertEqual(b_value['query'], b.values[0].query) + self.assertEqual(b_data, b.data) + + target = SimpleProvider() + # No changes with self + self.assertFalse(a.changes(a, target)) + # Diff in path causes change + other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].path = '/change' + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in target causes change + other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].target = 'http://target' + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in code causes change + other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].code = 302 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in masking causes change + other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].masking = 0 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + # Diff in query causes change + other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values}) + other.values[0].query = 1 + change = a.changes(other, target) + self.assertEqual(change.existing, a) + self.assertEqual(change.new, other) + + # hash + v = UrlfwdValue( + { + 'path': '/', + 'target': 'http://place', + 'code': 301, + 'masking': 2, + 'query': 0, + } + ) + o = UrlfwdValue( + { + 'path': '/location', + 'target': 'http://redirect', + 'code': 302, + 'masking': 2, + 'query': 0, + } + ) + values = set() + values.add(v) + self.assertTrue(v in values) + self.assertFalse(o in values) + values.add(o) + self.assertTrue(o in values) + + # __repr__ doesn't blow up + a.__repr__() + + def test_validation(self): + # doesn't blow up + Record.new( + self.zone, + '', + { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'masking': 2, + 'query': 0, + }, + }, + ) + Record.new( + self.zone, + '', + { + 'type': 'URLFWD', + 'ttl': 600, + 'values': [ + { + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'masking': 2, + 'query': 0, + }, + { + 'path': '/target', + 'target': 'http://target', + 'code': 302, + 'masking': 2, + 'query': 0, + }, + ], + }, + ) + + # missing path + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'target': 'http://foo', + 'code': 301, + 'masking': 2, + 'query': 0, + }, + }, + ) + self.assertEqual(['missing path'], ctx.exception.reasons) + + # missing target + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'code': 301, + 'masking': 2, + 'query': 0, + }, + }, + ) + self.assertEqual(['missing target'], ctx.exception.reasons) + + # missing code + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'masking': 2, + 'query': 0, + }, + }, + ) + self.assertEqual(['missing code'], ctx.exception.reasons) + + # invalid code + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 'nope', + 'masking': 2, + 'query': 0, + }, + }, + ) + self.assertEqual(['invalid return code "nope"'], ctx.exception.reasons) + + # unrecognized code + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 3, + 'masking': 2, + 'query': 0, + }, + }, + ) + self.assertEqual( + ['unrecognized return code "3"'], ctx.exception.reasons + ) + + # missing masking + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'query': 0, + }, + }, + ) + self.assertEqual(['missing masking'], ctx.exception.reasons) + + # invalid masking + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'masking': 'nope', + 'query': 0, + }, + }, + ) + self.assertEqual( + ['invalid masking setting "nope"'], ctx.exception.reasons + ) + + # unrecognized masking + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'masking': 3, + 'query': 0, + }, + }, + ) + self.assertEqual( + ['unrecognized masking setting "3"'], ctx.exception.reasons + ) + + # missing query + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'masking': 2, + }, + }, + ) + self.assertEqual(['missing query'], ctx.exception.reasons) + + # invalid query + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'masking': 2, + 'query': 'nope', + }, + }, + ) + self.assertEqual( + ['invalid query setting "nope"'], ctx.exception.reasons + ) + + # unrecognized query + with self.assertRaises(ValidationError) as ctx: + Record.new( + self.zone, + '', + { + 'type': 'URLFWD', + 'ttl': 600, + 'value': { + 'path': '/', + 'target': 'http://foo', + 'code': 301, + 'masking': 2, + 'query': 3, + }, + }, + ) + self.assertEqual( + ['unrecognized query setting "3"'], ctx.exception.reasons + )