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..7ef1d6b 100644 --- a/octodns/record/__init__.py +++ b/octodns/record/__init__.py @@ -2,2538 +2,79 @@ # # -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 - - -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 _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 - - @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 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 - # 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) - - -# 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) - - @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) +from .a import ARecord, Ipv4Address, Ipv4Value +from .aaaa import AaaaRecord, Ipv6Address, Ipv6Value +from .alias import AliasRecord, AliasValue +from .base import Record, ValueMixin, ValuesMixin +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 .spf import SpfRecord +from .srv import SrvRecord, SrvValue +from .sshfp import SshfpRecord, SshfpValue +from .tlsa import TlsaRecord, TlsaValue +from .txt import TxtValue, TxtRecord +from .urlfwd import UrlfwdRecord, UrlfwdValue + +# quell warnings +ARecord +AaaaRecord +AliasRecord +AliasValue +CaaRecord +CaaValue +Change +CnameRecord +CnameValue +Create +Delete +DnameRecord +DnameValue +DsRecord +DsValue +GeoCodes +GeoValue +Ipv4Address +Ipv4Value +Ipv6Address +Ipv6Value +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 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/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/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/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/chunked.py b/octodns/record/chunked.py new file mode 100644 index 0000000..8f8009e --- /dev/null +++ b/octodns/record/chunked.py @@ -0,0 +1,63 @@ +# +# +# + +from .base import 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 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/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/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/ip.py b/octodns/record/ip.py new file mode 100644 index 0000000..6b3fe3c --- /dev/null +++ b/octodns/record/ip.py @@ -0,0 +1,49 @@ +# +# +# + + +class _IpValue(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 + + +_IpAddress = _IpValue 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/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/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/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/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/target.py b/octodns/record/target.py new file mode 100644 index 0000000..f9dbc18 --- /dev/null +++ b/octodns/record/target.py @@ -0,0 +1,79 @@ +# +# +# + +from fqdn import FQDN + +from ..idna import idna_encode + + +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 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/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/octodns/record/urlfwd.py b/octodns/record/urlfwd.py new file mode 100644 index 0000000..04f9f6a --- /dev/null +++ b/octodns/record/urlfwd.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_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..5503fd8 100644 --- a/tests/test_octodns_record.py +++ b/tests/test_octodns_record.py @@ -7,55 +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, - PtrRecord, - PtrValue, + NsValue, Record, RecordException, Rr, - RrParseError, - SshfpRecord, - SshfpValue, - SpfRecord, - SrvRecord, SrvValue, - TlsaRecord, - TlsaValue, TxtRecord, Update, - UrlfwdRecord, - UrlfwdValue, ValidationError, ValuesMixin, - _ChunkedValue, - _Dynamic, - _DynamicPool, - _DynamicRule, - _NsValue, - _TargetValue, ) from octodns.zone import Zone -from helpers import DynamicProvider, GeoProvider, SimpleProvider - class TestRecord(TestCase): zone = Zone('unit.tests.', []) @@ -64,13 +31,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.a.ARecord', str(ctx.exception), ) class AaRecord(ValuesMixin, Record): _type = 'AA' - _value_type = _NsValue + _value_type = NsValue self.assertTrue('AA' not in Record.registered_types()) @@ -177,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 = ( @@ -332,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': []}) @@ -389,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'} @@ -2129,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'} @@ -2158,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, @@ -2386,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__() @@ -2933,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 + )