| @ -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) | |||
| @ -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) | |||
| @ -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) | |||
| @ -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}>' | |||
| @ -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) | |||
| @ -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}' | |||
| @ -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 | |||
| @ -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) | |||
| @ -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) | |||
| @ -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) | |||
| @ -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<continent_code>\w\w)(-(?P<country_code>\w\w)' | |||
| r'(-(?P<subdivision_code>\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__() | |||
| @ -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 | |||
| @ -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 | |||
| @ -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) | |||
| @ -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) | |||
| @ -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) | |||
| @ -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) | |||
| @ -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) | |||
| @ -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}' | |||
| @ -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) | |||
| @ -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) | |||
| @ -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) | |||
| @ -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 | |||
| @ -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) | |||
| @ -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) | |||
| @ -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) | |||
| @ -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, | |||
| ) | |||
| @ -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, | |||
| ) | |||
| @ -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, | |||
| ) | |||
| @ -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) | |||
| @ -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), | |||
| ) | |||
| @ -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) | |||
| @ -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, | |||
| ) | |||
| @ -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, | |||
| ) | |||
| @ -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__()) | |||
| @ -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) | |||
| @ -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 | |||
| ) | |||
| @ -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) | |||
| @ -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) | |||
| @ -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, | |||
| ) | |||
| @ -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) | |||
| @ -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, | |||
| ) | |||
| @ -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, | |||
| ) | |||
| @ -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) | |||
| @ -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) | |||
| @ -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, | |||
| ) | |||
| @ -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) | |||
| @ -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 | |||
| ) | |||