Browse Source

Merge remote-tracking branch 'origin' into delayed-arpa

pull/974/head
Ross McFarland 3 years ago
parent
commit
ac59552403
No known key found for this signature in database GPG Key ID: 943B179E15D3B22A
55 changed files with 9309 additions and 8669 deletions
  1. +6
    -0
      CHANGELOG.md
  2. +75
    -2534
      octodns/record/__init__.py
  3. +26
    -0
      octodns/record/a.py
  4. +26
    -0
      octodns/record/aaaa.py
  5. +26
    -0
      octodns/record/alias.py
  6. +340
    -0
      octodns/record/base.py
  7. +101
    -0
      octodns/record/caa.py
  8. +55
    -0
      octodns/record/change.py
  9. +63
    -0
      octodns/record/chunked.py
  10. +27
    -0
      octodns/record/cname.py
  11. +19
    -0
      octodns/record/dname.py
  12. +136
    -0
      octodns/record/ds.py
  13. +340
    -0
      octodns/record/dynamic.py
  14. +21
    -0
      octodns/record/exception.py
  15. +101
    -0
      octodns/record/geo.py
  16. +49
    -0
      octodns/record/ip.py
  17. +358
    -0
      octodns/record/loc.py
  18. +120
    -0
      octodns/record/mx.py
  19. +172
    -0
      octodns/record/naptr.py
  20. +18
    -0
      octodns/record/ns.py
  21. +24
    -0
      octodns/record/ptr.py
  22. +27
    -0
      octodns/record/rr.py
  23. +14
    -0
      octodns/record/spf.py
  24. +158
    -0
      octodns/record/srv.py
  25. +124
    -0
      octodns/record/sshfp.py
  26. +79
    -0
      octodns/record/target.py
  27. +160
    -0
      octodns/record/tlsa.py
  28. +18
    -0
      octodns/record/txt.py
  29. +121
    -0
      octodns/record/urlfwd.py
  30. +2
    -2
      tests/test_octodns_provider_yaml.py
  31. +259
    -6132
      tests/test_octodns_record.py
  32. +181
    -0
      tests/test_octodns_record_a.py
  33. +227
    -0
      tests/test_octodns_record_aaaa.py
  34. +108
    -0
      tests/test_octodns_record_alias.py
  35. +273
    -0
      tests/test_octodns_record_caa.py
  36. +92
    -0
      tests/test_octodns_record_change.py
  37. +37
    -0
      tests/test_octodns_record_chunked.py
  38. +136
    -0
      tests/test_octodns_record_cname.py
  39. +94
    -0
      tests/test_octodns_record_dname.py
  40. +206
    -0
      tests/test_octodns_record_ds.py
  41. +1284
    -0
      tests/test_octodns_record_dynamic.py
  42. +184
    -1
      tests/test_octodns_record_geo.py
  43. +30
    -0
      tests/test_octodns_record_ip.py
  44. +697
    -0
      tests/test_octodns_record_loc.py
  45. +265
    -0
      tests/test_octodns_record_mx.py
  46. +438
    -0
      tests/test_octodns_record_naptr.py
  47. +83
    -0
      tests/test_octodns_record_ns.py
  48. +89
    -0
      tests/test_octodns_record_ptr.py
  49. +71
    -0
      tests/test_octodns_record_spf.py
  50. +432
    -0
      tests/test_octodns_record_srv.py
  51. +330
    -0
      tests/test_octodns_record_sshfp.py
  52. +31
    -0
      tests/test_octodns_record_target.py
  53. +421
    -0
      tests/test_octodns_record_tlsa.py
  54. +144
    -0
      tests/test_octodns_record_txt.py
  55. +391
    -0
      tests/test_octodns_record_urlfwd.py

+ 6
- 0
CHANGELOG.md View File

@ -8,6 +8,12 @@
modules now.
* Provider.strict_supports defaults to true, can be returned to the old
behavior by setting strict_supports=False in your provider params.
* octodns.record has been broken up into multiple files/modules. Most of the
primary things that were available at that module path still will be, but if
you are importing things like idna_encode/decode that actually live elsewhere
from octodns.record you'll need to update and pull them from their actual
home. Classes beginning with _ are not exported from octodns.record any
longer as they were considered private/protected.
#### Stuff


+ 75
- 2534
octodns/record/__init__.py
File diff suppressed because it is too large
View File


+ 26
- 0
octodns/record/a.py View File

@ -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)

+ 26
- 0
octodns/record/aaaa.py View File

@ -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)

+ 26
- 0
octodns/record/alias.py View File

@ -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)

+ 340
- 0
octodns/record/base.py View File

@ -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}>'

+ 101
- 0
octodns/record/caa.py View File

@ -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)

+ 55
- 0
octodns/record/change.py View File

@ -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}'

+ 63
- 0
octodns/record/chunked.py View File

@ -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

+ 27
- 0
octodns/record/cname.py View File

@ -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)

+ 19
- 0
octodns/record/dname.py View File

@ -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)

+ 136
- 0
octodns/record/ds.py View File

@ -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)

+ 340
- 0
octodns/record/dynamic.py View File

@ -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__()

+ 21
- 0
octodns/record/exception.py View File

@ -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

+ 101
- 0
octodns/record/geo.py View File

@ -3,7 +3,11 @@
#
from logging import getLogger
import re
from ..equality import EqualityTupleMixin
from .base import ValuesMixin
from .change import Update
from .geo_data import geo_data
@ -79,3 +83,100 @@ class GeoCodes(object):
if province in geo_data['NA']['CA']['provinces']:
country = 'CA'
return f'NA-{country}-{province}'
class GeoValue(EqualityTupleMixin):
geo_re = re.compile(
r'^(?P<continent_code>\w\w)(-(?P<country_code>\w\w)'
r'(-(?P<subdivision_code>\w\w))?)?$'
)
@classmethod
def _validate_geo(cls, code):
reasons = []
match = cls.geo_re.match(code)
if not match:
reasons.append(f'invalid geo "{code}"')
return reasons
def __init__(self, geo, values):
self.code = geo
match = self.geo_re.match(geo)
self.continent_code = match.group('continent_code')
self.country_code = match.group('country_code')
self.subdivision_code = match.group('subdivision_code')
self.values = sorted(values)
@property
def parents(self):
bits = self.code.split('-')[:-1]
while bits:
yield '-'.join(bits)
bits.pop()
def _equality_tuple(self):
return (
self.continent_code,
self.country_code,
self.subdivision_code,
self.values,
)
def __repr__(self):
return (
f"'Geo {self.continent_code} {self.country_code} "
"{self.subdivision_code} {self.values}'"
)
class _GeoMixin(ValuesMixin):
'''
Adds GeoDNS support to a record.
Must be included before `Record`.
'''
@classmethod
def validate(cls, name, fqdn, data):
reasons = super().validate(name, fqdn, data)
try:
geo = dict(data['geo'])
for code, values in geo.items():
reasons.extend(GeoValue._validate_geo(code))
reasons.extend(cls._value_type.validate(values, cls._type))
except KeyError:
pass
return reasons
def __init__(self, zone, name, data, *args, **kwargs):
super().__init__(zone, name, data, *args, **kwargs)
try:
self.geo = dict(data['geo'])
except KeyError:
self.geo = {}
for code, values in self.geo.items():
self.geo[code] = GeoValue(code, values)
def _data(self):
ret = super()._data()
if self.geo:
geo = {}
for code, value in self.geo.items():
geo[code] = value.values
ret['geo'] = geo
return ret
def changes(self, other, target):
if target.SUPPORTS_GEO:
if self.geo != other.geo:
return Update(self, other)
return super().changes(other, target)
def __repr__(self):
if self.geo:
klass = self.__class__.__name__
return (
f'<{klass} {self._type} {self.ttl}, {self.decoded_fqdn}, '
f'{self.values}, {self.geo}>'
)
return super().__repr__()

+ 49
- 0
octodns/record/ip.py View File

@ -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

+ 358
- 0
octodns/record/loc.py View File

@ -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)

+ 120
- 0
octodns/record/mx.py View File

@ -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)

+ 172
- 0
octodns/record/naptr.py View File

@ -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)

+ 18
- 0
octodns/record/ns.py View File

@ -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)

+ 24
- 0
octodns/record/ptr.py View File

@ -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)

+ 27
- 0
octodns/record/rr.py View File

@ -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}'

+ 14
- 0
octodns/record/spf.py View File

@ -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)

+ 158
- 0
octodns/record/srv.py View File

@ -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)

+ 124
- 0
octodns/record/sshfp.py View File

@ -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)

+ 79
- 0
octodns/record/target.py View File

@ -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

+ 160
- 0
octodns/record/tlsa.py View File

@ -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)

+ 18
- 0
octodns/record/txt.py View File

@ -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)

+ 121
- 0
octodns/record/urlfwd.py View File

@ -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)

+ 2
- 2
tests/test_octodns_provider_yaml.py View File

@ -9,7 +9,7 @@ from yaml import safe_load
from yaml.constructor import ConstructorError
from octodns.idna import idna_encode
from octodns.record import _NsValue, Create, Record, ValuesMixin
from octodns.record import NsValue, Create, Record, ValuesMixin
from octodns.provider import ProviderException
from octodns.provider.base import Plan
from octodns.provider.yaml import (
@ -273,7 +273,7 @@ xn--dj-kia8a:
class YamlRecord(ValuesMixin, Record):
_type = 'YAML'
_value_type = _NsValue
_value_type = NsValue
# don't know anything about a yaml type
self.assertTrue('YAML' not in source.SUPPORTS)


+ 259
- 6132
tests/test_octodns_record.py
File diff suppressed because it is too large
View File


+ 181
- 0
tests/test_octodns_record_a.py View File

@ -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,
)

+ 227
- 0
tests/test_octodns_record_aaaa.py View File

@ -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,
)

+ 108
- 0
tests/test_octodns_record_alias.py View File

@ -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,
)

+ 273
- 0
tests/test_octodns_record_caa.py View File

@ -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)

+ 92
- 0
tests/test_octodns_record_change.py View File

@ -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),
)

+ 37
- 0
tests/test_octodns_record_chunked.py View File

@ -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)

+ 136
- 0
tests/test_octodns_record_cname.py View File

@ -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,
)

+ 94
- 0
tests/test_octodns_record_dname.py View File

@ -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,
)

+ 206
- 0
tests/test_octodns_record_ds.py View File

@ -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__())

+ 1284
- 0
tests/test_octodns_record_dynamic.py
File diff suppressed because it is too large
View File


+ 184
- 1
tests/test_octodns_record_geo.py View File

@ -4,10 +4,74 @@
from unittest import TestCase
from octodns.record.geo import GeoCodes
from octodns.record import Record
from octodns.record.a import ARecord
from octodns.record.geo import GeoCodes, GeoValue
from octodns.record.exception import ValidationError
from octodns.zone import Zone
from helpers import SimpleProvider, GeoProvider
class TestRecordGeo(TestCase):
zone = Zone('unit.tests.', [])
def test_geo(self):
geo_data = {
'ttl': 42,
'values': ['5.2.3.4', '6.2.3.4'],
'geo': {
'AF': ['1.1.1.1'],
'AS-JP': ['2.2.2.2', '3.3.3.3'],
'NA-US': ['4.4.4.4', '5.5.5.5'],
'NA-US-CA': ['6.6.6.6', '7.7.7.7'],
},
}
geo = ARecord(self.zone, 'geo', geo_data)
self.assertEqual(geo_data, geo.data)
other_data = {
'ttl': 42,
'values': ['5.2.3.4', '6.2.3.4'],
'geo': {
'AF': ['1.1.1.1'],
'AS-JP': ['2.2.2.2', '3.3.3.3'],
'NA-US': ['4.4.4.4', '5.5.5.5'],
'NA-US-CA': ['6.6.6.6', '7.7.7.7'],
},
}
other = ARecord(self.zone, 'geo', other_data)
self.assertEqual(other_data, other.data)
simple_target = SimpleProvider()
geo_target = GeoProvider()
# Geo provider doesn't consider identical geo to be changes
self.assertFalse(geo.changes(geo, geo_target))
# geo values don't impact equality
other.geo['AF'].values = ['9.9.9.9']
self.assertTrue(geo == other)
# Non-geo supporting provider doesn't consider geo diffs to be changes
self.assertFalse(geo.changes(other, simple_target))
# Geo provider does consider geo diffs to be changes
self.assertTrue(geo.changes(other, geo_target))
# Object without geo doesn't impact equality
other.geo = {}
self.assertTrue(geo == other)
# Non-geo supporting provider doesn't consider lack of geo a diff
self.assertFalse(geo.changes(other, simple_target))
# Geo provider does consider lack of geo diffs to be changes
self.assertTrue(geo.changes(other, geo_target))
# __repr__ doesn't blow up
geo.__repr__()
class TestRecordGeoCodes(TestCase):
zone = Zone('unit.tests.', [])
def test_validate(self):
prefix = 'xyz '
@ -104,3 +168,122 @@ class TestRecordGeoCodes(TestCase):
self.assertEqual('NA-CA-AB', GeoCodes.province_to_code('AB'))
self.assertEqual('NA-CA-BC', GeoCodes.province_to_code('BC'))
self.assertFalse(GeoCodes.province_to_code('XX'))
def test_geo_value(self):
code = 'NA-US-CA'
values = ['1.2.3.4']
geo = GeoValue(code, values)
self.assertEqual(code, geo.code)
self.assertEqual('NA', geo.continent_code)
self.assertEqual('US', geo.country_code)
self.assertEqual('CA', geo.subdivision_code)
self.assertEqual(values, geo.values)
self.assertEqual(['NA-US', 'NA'], list(geo.parents))
a = GeoValue('NA-US-CA', values)
b = GeoValue('AP-JP', values)
c = GeoValue('NA-US-CA', ['2.3.4.5'])
self.assertEqual(a, a)
self.assertEqual(b, b)
self.assertEqual(c, c)
self.assertNotEqual(a, b)
self.assertNotEqual(a, c)
self.assertNotEqual(b, a)
self.assertNotEqual(b, c)
self.assertNotEqual(c, a)
self.assertNotEqual(c, b)
self.assertTrue(a > b)
self.assertTrue(a < c)
self.assertTrue(b < a)
self.assertTrue(b < c)
self.assertTrue(c > a)
self.assertTrue(c > b)
self.assertTrue(a >= a)
self.assertTrue(a >= b)
self.assertTrue(a <= c)
self.assertTrue(b <= a)
self.assertTrue(b <= b)
self.assertTrue(b <= c)
self.assertTrue(c > a)
self.assertTrue(c > b)
self.assertTrue(c >= b)
def test_validation(self):
Record.new(
self.zone,
'',
{
'geo': {'NA': ['1.2.3.5'], 'NA-US': ['1.2.3.5', '1.2.3.6']},
'type': 'A',
'ttl': 600,
'value': '1.2.3.4',
},
)
# invalid ip address
with self.assertRaises(ValidationError) as ctx:
Record.new(
self.zone,
'',
{
'geo': {'NA': ['hello'], 'NA-US': ['1.2.3.5', '1.2.3.6']},
'type': 'A',
'ttl': 600,
'value': '1.2.3.4',
},
)
self.assertEqual(
['invalid IPv4 address "hello"'], ctx.exception.reasons
)
# invalid geo code
with self.assertRaises(ValidationError) as ctx:
Record.new(
self.zone,
'',
{
'geo': {'XYZ': ['1.2.3.4']},
'type': 'A',
'ttl': 600,
'value': '1.2.3.4',
},
)
self.assertEqual(['invalid geo "XYZ"'], ctx.exception.reasons)
# invalid ip address
with self.assertRaises(ValidationError) as ctx:
Record.new(
self.zone,
'',
{
'geo': {'NA': ['hello'], 'NA-US': ['1.2.3.5', 'goodbye']},
'type': 'A',
'ttl': 600,
'value': '1.2.3.4',
},
)
self.assertEqual(
['invalid IPv4 address "hello"', 'invalid IPv4 address "goodbye"'],
ctx.exception.reasons,
)
# invalid healthcheck protocol
with self.assertRaises(ValidationError) as ctx:
Record.new(
self.zone,
'a',
{
'geo': {'NA': ['1.2.3.5'], 'NA-US': ['1.2.3.5', '1.2.3.6']},
'type': 'A',
'ttl': 600,
'value': '1.2.3.4',
'octodns': {'healthcheck': {'protocol': 'FTP'}},
},
)
self.assertEqual(
['invalid healthcheck protocol'], ctx.exception.reasons
)

+ 30
- 0
tests/test_octodns_record_ip.py View File

@ -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)

+ 697
- 0
tests/test_octodns_record_loc.py View File

@ -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
)

+ 265
- 0
tests/test_octodns_record_mx.py View File

@ -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)

+ 438
- 0
tests/test_octodns_record_naptr.py View File

@ -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)

+ 83
- 0
tests/test_octodns_record_ns.py View File

@ -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,
)

+ 89
- 0
tests/test_octodns_record_ptr.py View File

@ -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)

+ 71
- 0
tests/test_octodns_record_spf.py View File

@ -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,
)

+ 432
- 0
tests/test_octodns_record_srv.py View File

@ -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,
)

+ 330
- 0
tests/test_octodns_record_sshfp.py View File

@ -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)

+ 31
- 0
tests/test_octodns_record_target.py View File

@ -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)

+ 421
- 0
tests/test_octodns_record_tlsa.py View File

@ -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,
)

+ 144
- 0
tests/test_octodns_record_txt.py View File

@ -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)

+ 391
- 0
tests/test_octodns_record_urlfwd.py View File

@ -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
)

Loading…
Cancel
Save