mirror of
https://github.com/github/octodns.git
synced 2024-05-11 05:55:00 +00:00
Merge remote-tracking branch 'origin' into delayed-arpa
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from ipaddress import IPv4Address as _IPv4Address
|
||||
|
||||
from .base import Record
|
||||
from .dynamic import _DynamicMixin
|
||||
from .geo import _GeoMixin
|
||||
from .ip import _IpValue
|
||||
|
||||
|
||||
class Ipv4Value(_IpValue):
|
||||
_address_type = _IPv4Address
|
||||
_address_name = 'IPv4'
|
||||
|
||||
|
||||
Ipv4Address = Ipv4Value
|
||||
|
||||
|
||||
class ARecord(_DynamicMixin, _GeoMixin, Record):
|
||||
_type = 'A'
|
||||
_value_type = Ipv4Value
|
||||
|
||||
|
||||
Record.register_type(ARecord)
|
||||
@@ -0,0 +1,26 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from ipaddress import IPv6Address as _IPv6Address
|
||||
|
||||
from .base import Record
|
||||
from .dynamic import _DynamicMixin
|
||||
from .geo import _GeoMixin
|
||||
from .ip import _IpValue
|
||||
|
||||
|
||||
class Ipv6Value(_IpValue):
|
||||
_address_type = _IPv6Address
|
||||
_address_name = 'IPv6'
|
||||
|
||||
|
||||
Ipv6Address = Ipv6Value
|
||||
|
||||
|
||||
class AaaaRecord(_DynamicMixin, _GeoMixin, Record):
|
||||
_type = 'AAAA'
|
||||
_value_type = Ipv6Address
|
||||
|
||||
|
||||
Record.register_type(AaaaRecord)
|
||||
@@ -0,0 +1,26 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from .base import Record, ValueMixin
|
||||
from .target import _TargetValue
|
||||
|
||||
|
||||
class AliasValue(_TargetValue):
|
||||
pass
|
||||
|
||||
|
||||
class AliasRecord(ValueMixin, Record):
|
||||
_type = 'ALIAS'
|
||||
_value_type = AliasValue
|
||||
|
||||
@classmethod
|
||||
def validate(cls, name, fqdn, data):
|
||||
reasons = []
|
||||
if name != '':
|
||||
reasons.append('non-root ALIAS not allowed')
|
||||
reasons.extend(super().validate(name, fqdn, data))
|
||||
return reasons
|
||||
|
||||
|
||||
Record.register_type(AliasRecord)
|
||||
@@ -0,0 +1,340 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from collections import defaultdict
|
||||
from logging import getLogger
|
||||
|
||||
from ..equality import EqualityTupleMixin
|
||||
from ..idna import IdnaError, idna_decode, idna_encode
|
||||
from .change import Update
|
||||
from .exception import RecordException, ValidationError
|
||||
|
||||
|
||||
class Record(EqualityTupleMixin):
|
||||
log = getLogger('Record')
|
||||
|
||||
_CLASSES = {}
|
||||
|
||||
@classmethod
|
||||
def register_type(cls, _class, _type=None):
|
||||
if _type is None:
|
||||
_type = _class._type
|
||||
existing = cls._CLASSES.get(_type)
|
||||
if existing:
|
||||
module = existing.__module__
|
||||
name = existing.__name__
|
||||
msg = f'Type "{_type}" already registered by {module}.{name}'
|
||||
raise RecordException(msg)
|
||||
cls._CLASSES[_type] = _class
|
||||
|
||||
@classmethod
|
||||
def registered_types(cls):
|
||||
return cls._CLASSES
|
||||
|
||||
@classmethod
|
||||
def new(cls, zone, name, data, source=None, lenient=False):
|
||||
reasons = []
|
||||
try:
|
||||
name = idna_encode(str(name))
|
||||
except IdnaError as e:
|
||||
# convert the error into a reason
|
||||
reasons.append(str(e))
|
||||
name = str(name)
|
||||
fqdn = f'{name}.{zone.name}' if name else zone.name
|
||||
try:
|
||||
_type = data['type']
|
||||
except KeyError:
|
||||
raise Exception(f'Invalid record {idna_decode(fqdn)}, missing type')
|
||||
try:
|
||||
_class = cls._CLASSES[_type]
|
||||
except KeyError:
|
||||
raise Exception(f'Unknown record type: "{_type}"')
|
||||
reasons.extend(_class.validate(name, fqdn, data))
|
||||
try:
|
||||
lenient |= data['octodns']['lenient']
|
||||
except KeyError:
|
||||
pass
|
||||
if reasons:
|
||||
if lenient:
|
||||
cls.log.warning(ValidationError.build_message(fqdn, reasons))
|
||||
else:
|
||||
raise ValidationError(fqdn, reasons)
|
||||
return _class(zone, name, data, source=source)
|
||||
|
||||
@classmethod
|
||||
def validate(cls, name, fqdn, data):
|
||||
reasons = []
|
||||
if name == '@':
|
||||
reasons.append('invalid name "@", use "" instead')
|
||||
n = len(fqdn)
|
||||
if n > 253:
|
||||
reasons.append(
|
||||
f'invalid fqdn, "{idna_decode(fqdn)}" is too long at {n} '
|
||||
'chars, max is 253'
|
||||
)
|
||||
for label in name.split('.'):
|
||||
n = len(label)
|
||||
if n > 63:
|
||||
reasons.append(
|
||||
f'invalid label, "{label}" is too long at {n}'
|
||||
' chars, max is 63'
|
||||
)
|
||||
# TODO: look at the idna lib for a lot more potential validations...
|
||||
try:
|
||||
ttl = int(data['ttl'])
|
||||
if ttl < 0:
|
||||
reasons.append('invalid ttl')
|
||||
except KeyError:
|
||||
reasons.append('missing ttl')
|
||||
try:
|
||||
if data['octodns']['healthcheck']['protocol'] not in (
|
||||
'HTTP',
|
||||
'HTTPS',
|
||||
'TCP',
|
||||
):
|
||||
reasons.append('invalid healthcheck protocol')
|
||||
except KeyError:
|
||||
pass
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def from_rrs(cls, zone, rrs, lenient=False):
|
||||
# group records by name & type so that multiple rdatas can be combined
|
||||
# into a single record when needed
|
||||
grouped = defaultdict(list)
|
||||
for rr in rrs:
|
||||
grouped[(rr.name, rr._type)].append(rr)
|
||||
|
||||
records = []
|
||||
# walk the grouped rrs converting each one to data and then create a
|
||||
# record with that data
|
||||
for _, rrs in sorted(grouped.items()):
|
||||
rr = rrs[0]
|
||||
name = zone.hostname_from_fqdn(rr.name)
|
||||
_class = cls._CLASSES[rr._type]
|
||||
data = _class.data_from_rrs(rrs)
|
||||
record = Record.new(zone, name, data, lenient=lenient)
|
||||
records.append(record)
|
||||
|
||||
return records
|
||||
|
||||
def __init__(self, zone, name, data, source=None):
|
||||
self.zone = zone
|
||||
if name:
|
||||
# internally everything is idna
|
||||
self.name = idna_encode(str(name))
|
||||
# we'll keep a decoded version around for logs and errors
|
||||
self.decoded_name = idna_decode(self.name)
|
||||
else:
|
||||
self.name = self.decoded_name = name
|
||||
self.log.debug(
|
||||
'__init__: zone.name=%s, type=%11s, name=%s',
|
||||
zone.decoded_name,
|
||||
self.__class__.__name__,
|
||||
self.decoded_name,
|
||||
)
|
||||
self.source = source
|
||||
self.ttl = int(data['ttl'])
|
||||
|
||||
self._octodns = data.get('octodns', {})
|
||||
|
||||
def _data(self):
|
||||
return {'ttl': self.ttl}
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self._data()
|
||||
|
||||
@property
|
||||
def fqdn(self):
|
||||
# TODO: these should be calculated and set in __init__ rather than on
|
||||
# each use
|
||||
if self.name:
|
||||
return f'{self.name}.{self.zone.name}'
|
||||
return self.zone.name
|
||||
|
||||
@property
|
||||
def decoded_fqdn(self):
|
||||
if self.decoded_name:
|
||||
return f'{self.decoded_name}.{self.zone.decoded_name}'
|
||||
return self.zone.decoded_name
|
||||
|
||||
@property
|
||||
def ignored(self):
|
||||
return self._octodns.get('ignored', False)
|
||||
|
||||
@property
|
||||
def excluded(self):
|
||||
return self._octodns.get('excluded', [])
|
||||
|
||||
@property
|
||||
def included(self):
|
||||
return self._octodns.get('included', [])
|
||||
|
||||
def healthcheck_host(self, value=None):
|
||||
healthcheck = self._octodns.get('healthcheck', {})
|
||||
if healthcheck.get('protocol', None) == 'TCP':
|
||||
return None
|
||||
return healthcheck.get('host', self.fqdn[:-1]) or value
|
||||
|
||||
@property
|
||||
def healthcheck_path(self):
|
||||
healthcheck = self._octodns.get('healthcheck', {})
|
||||
if healthcheck.get('protocol', None) == 'TCP':
|
||||
return None
|
||||
try:
|
||||
return healthcheck['path']
|
||||
except KeyError:
|
||||
return '/_dns'
|
||||
|
||||
@property
|
||||
def healthcheck_protocol(self):
|
||||
try:
|
||||
return self._octodns['healthcheck']['protocol']
|
||||
except KeyError:
|
||||
return 'HTTPS'
|
||||
|
||||
@property
|
||||
def healthcheck_port(self):
|
||||
try:
|
||||
return int(self._octodns['healthcheck']['port'])
|
||||
except KeyError:
|
||||
return 443
|
||||
|
||||
def changes(self, other, target):
|
||||
# We're assuming we have the same name and type if we're being compared
|
||||
if self.ttl != other.ttl:
|
||||
return Update(self, other)
|
||||
|
||||
def copy(self, zone=None):
|
||||
data = self.data
|
||||
data['type'] = self._type
|
||||
data['octodns'] = self._octodns
|
||||
|
||||
return Record.new(
|
||||
zone if zone else self.zone,
|
||||
self.name,
|
||||
data,
|
||||
self.source,
|
||||
lenient=True,
|
||||
)
|
||||
|
||||
# NOTE: we're using __hash__ and ordering methods that consider Records
|
||||
# equivalent if they have the same name & _type. Values are ignored. This
|
||||
# is useful when computing diffs/changes.
|
||||
|
||||
def __hash__(self):
|
||||
return f'{self.name}:{self._type}'.__hash__()
|
||||
|
||||
def _equality_tuple(self):
|
||||
return (self.name, self._type)
|
||||
|
||||
def __repr__(self):
|
||||
# Make sure this is always overridden
|
||||
raise NotImplementedError('Abstract base class, __repr__ required')
|
||||
|
||||
|
||||
class ValuesMixin(object):
|
||||
@classmethod
|
||||
def validate(cls, name, fqdn, data):
|
||||
reasons = super().validate(name, fqdn, data)
|
||||
|
||||
values = data.get('values', data.get('value', []))
|
||||
|
||||
reasons.extend(cls._value_type.validate(values, cls._type))
|
||||
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def data_from_rrs(cls, rrs):
|
||||
# type and TTL come from the first rr
|
||||
rr = rrs[0]
|
||||
# values come from parsing the rdata portion of all rrs
|
||||
values = [cls._value_type.parse_rdata_text(rr.rdata) for rr in rrs]
|
||||
return {'ttl': rr.ttl, 'type': rr._type, 'values': values}
|
||||
|
||||
def __init__(self, zone, name, data, source=None):
|
||||
super().__init__(zone, name, data, source=source)
|
||||
try:
|
||||
values = data['values']
|
||||
except KeyError:
|
||||
values = [data['value']]
|
||||
self.values = sorted(self._value_type.process(values))
|
||||
|
||||
def changes(self, other, target):
|
||||
if self.values != other.values:
|
||||
return Update(self, other)
|
||||
return super().changes(other, target)
|
||||
|
||||
def _data(self):
|
||||
ret = super()._data()
|
||||
if len(self.values) > 1:
|
||||
values = [getattr(v, 'data', v) for v in self.values if v]
|
||||
if len(values) > 1:
|
||||
ret['values'] = values
|
||||
elif len(values) == 1:
|
||||
ret['value'] = values[0]
|
||||
elif len(self.values) == 1:
|
||||
v = self.values[0]
|
||||
if v:
|
||||
ret['value'] = getattr(v, 'data', v)
|
||||
|
||||
return ret
|
||||
|
||||
@property
|
||||
def rrs(self):
|
||||
return (
|
||||
self.fqdn,
|
||||
self.ttl,
|
||||
self._type,
|
||||
[v.rdata_text for v in self.values],
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
values = "', '".join([str(v) for v in self.values])
|
||||
klass = self.__class__.__name__
|
||||
return f"<{klass} {self._type} {self.ttl}, {self.decoded_fqdn}, ['{values}']>"
|
||||
|
||||
|
||||
class ValueMixin(object):
|
||||
@classmethod
|
||||
def validate(cls, name, fqdn, data):
|
||||
reasons = super().validate(name, fqdn, data)
|
||||
reasons.extend(
|
||||
cls._value_type.validate(data.get('value', None), cls._type)
|
||||
)
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def data_from_rrs(cls, rrs):
|
||||
# single value, so single rr only...
|
||||
rr = rrs[0]
|
||||
return {
|
||||
'ttl': rr.ttl,
|
||||
'type': rr._type,
|
||||
'value': cls._value_type.parse_rdata_text(rr.rdata),
|
||||
}
|
||||
|
||||
def __init__(self, zone, name, data, source=None):
|
||||
super().__init__(zone, name, data, source=source)
|
||||
self.value = self._value_type.process(data['value'])
|
||||
|
||||
def changes(self, other, target):
|
||||
if self.value != other.value:
|
||||
return Update(self, other)
|
||||
return super().changes(other, target)
|
||||
|
||||
def _data(self):
|
||||
ret = super()._data()
|
||||
if self.value:
|
||||
ret['value'] = getattr(self.value, 'data', self.value)
|
||||
return ret
|
||||
|
||||
@property
|
||||
def rrs(self):
|
||||
return self.fqdn, self.ttl, self._type, [self.value.rdata_text]
|
||||
|
||||
def __repr__(self):
|
||||
klass = self.__class__.__name__
|
||||
return f'<{klass} {self._type} {self.ttl}, {self.decoded_fqdn}, {self.value}>'
|
||||
@@ -0,0 +1,101 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from ..equality import EqualityTupleMixin
|
||||
from .base import Record, ValuesMixin
|
||||
from .rr import RrParseError
|
||||
|
||||
|
||||
class CaaValue(EqualityTupleMixin, dict):
|
||||
# https://tools.ietf.org/html/rfc6844#page-5
|
||||
|
||||
@classmethod
|
||||
def parse_rdata_text(cls, value):
|
||||
try:
|
||||
flags, tag, value = value.split(' ')
|
||||
except ValueError:
|
||||
raise RrParseError()
|
||||
try:
|
||||
flags = int(flags)
|
||||
except ValueError:
|
||||
pass
|
||||
return {'flags': flags, 'tag': tag, 'value': value}
|
||||
|
||||
@classmethod
|
||||
def validate(cls, data, _type):
|
||||
if not isinstance(data, (list, tuple)):
|
||||
data = (data,)
|
||||
reasons = []
|
||||
for value in data:
|
||||
try:
|
||||
flags = int(value.get('flags', 0))
|
||||
if flags < 0 or flags > 255:
|
||||
reasons.append(f'invalid flags "{flags}"')
|
||||
except ValueError:
|
||||
reasons.append(f'invalid flags "{value["flags"]}"')
|
||||
|
||||
if 'tag' not in value:
|
||||
reasons.append('missing tag')
|
||||
if 'value' not in value:
|
||||
reasons.append('missing value')
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def process(cls, values):
|
||||
return [cls(v) for v in values]
|
||||
|
||||
def __init__(self, value):
|
||||
super().__init__(
|
||||
{
|
||||
'flags': int(value.get('flags', 0)),
|
||||
'tag': value['tag'],
|
||||
'value': value['value'],
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def flags(self):
|
||||
return self['flags']
|
||||
|
||||
@flags.setter
|
||||
def flags(self, value):
|
||||
self['flags'] = value
|
||||
|
||||
@property
|
||||
def tag(self):
|
||||
return self['tag']
|
||||
|
||||
@tag.setter
|
||||
def tag(self, value):
|
||||
self['tag'] = value
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return self['value']
|
||||
|
||||
@value.setter
|
||||
def value(self, value):
|
||||
self['value'] = value
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def rdata_text(self):
|
||||
return f'{self.flags} {self.tag} {self.value}'
|
||||
|
||||
def _equality_tuple(self):
|
||||
return (self.flags, self.tag, self.value)
|
||||
|
||||
def __repr__(self):
|
||||
return f'{self.flags} {self.tag} "{self.value}"'
|
||||
|
||||
|
||||
class CaaRecord(ValuesMixin, Record):
|
||||
_type = 'CAA'
|
||||
_value_type = CaaValue
|
||||
|
||||
|
||||
Record.register_type(CaaRecord)
|
||||
@@ -0,0 +1,55 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from ..equality import EqualityTupleMixin
|
||||
|
||||
|
||||
class Change(EqualityTupleMixin):
|
||||
def __init__(self, existing, new):
|
||||
self.existing = existing
|
||||
self.new = new
|
||||
|
||||
@property
|
||||
def record(self):
|
||||
'Returns new if we have one, existing otherwise'
|
||||
return self.new or self.existing
|
||||
|
||||
def _equality_tuple(self):
|
||||
return (self.CLASS_ORDERING, self.record.name, self.record._type)
|
||||
|
||||
|
||||
class Create(Change):
|
||||
CLASS_ORDERING = 1
|
||||
|
||||
def __init__(self, new):
|
||||
super().__init__(None, new)
|
||||
|
||||
def __repr__(self, leader=''):
|
||||
source = self.new.source.id if self.new.source else ''
|
||||
return f'Create {self.new} ({source})'
|
||||
|
||||
|
||||
class Update(Change):
|
||||
CLASS_ORDERING = 2
|
||||
|
||||
# Leader is just to allow us to work around heven eating leading whitespace
|
||||
# in our output. When we call this from the Manager.sync plan summary
|
||||
# section we'll pass in a leader, otherwise we'll just let it default and
|
||||
# do nothing
|
||||
def __repr__(self, leader=''):
|
||||
source = self.new.source.id if self.new.source else ''
|
||||
return (
|
||||
f'Update\n{leader} {self.existing} ->\n'
|
||||
f'{leader} {self.new} ({source})'
|
||||
)
|
||||
|
||||
|
||||
class Delete(Change):
|
||||
CLASS_ORDERING = 0
|
||||
|
||||
def __init__(self, existing):
|
||||
super().__init__(existing, None)
|
||||
|
||||
def __repr__(self, leader=''):
|
||||
return f'Delete {self.existing}'
|
||||
@@ -0,0 +1,63 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from .base import ValuesMixin
|
||||
import re
|
||||
|
||||
|
||||
class _ChunkedValuesMixin(ValuesMixin):
|
||||
CHUNK_SIZE = 255
|
||||
_unescaped_semicolon_re = re.compile(r'\w;')
|
||||
|
||||
def chunked_value(self, value):
|
||||
value = value.replace('"', '\\"')
|
||||
vs = [
|
||||
value[i : i + self.CHUNK_SIZE]
|
||||
for i in range(0, len(value), self.CHUNK_SIZE)
|
||||
]
|
||||
vs = '" "'.join(vs)
|
||||
return f'"{vs}"'
|
||||
|
||||
@property
|
||||
def chunked_values(self):
|
||||
values = []
|
||||
for v in self.values:
|
||||
values.append(self.chunked_value(v))
|
||||
return values
|
||||
|
||||
|
||||
class _ChunkedValue(str):
|
||||
_unescaped_semicolon_re = re.compile(r'\w;')
|
||||
|
||||
@classmethod
|
||||
def parse_rdata_text(cls, value):
|
||||
try:
|
||||
return value.replace(';', '\\;')
|
||||
except AttributeError:
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def validate(cls, data, _type):
|
||||
if not data:
|
||||
return ['missing value(s)']
|
||||
elif not isinstance(data, (list, tuple)):
|
||||
data = (data,)
|
||||
reasons = []
|
||||
for value in data:
|
||||
if cls._unescaped_semicolon_re.search(value):
|
||||
reasons.append(f'unescaped ; in "{value}"')
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def process(cls, values):
|
||||
ret = []
|
||||
for v in values:
|
||||
if v and v[0] == '"':
|
||||
v = v[1:-1]
|
||||
ret.append(cls(v.replace('" "', '')))
|
||||
return ret
|
||||
|
||||
@property
|
||||
def rdata_text(self):
|
||||
return self
|
||||
@@ -0,0 +1,27 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from .base import Record, ValueMixin
|
||||
from .dynamic import _DynamicMixin
|
||||
from .target import _TargetValue
|
||||
|
||||
|
||||
class CnameValue(_TargetValue):
|
||||
pass
|
||||
|
||||
|
||||
class CnameRecord(_DynamicMixin, ValueMixin, Record):
|
||||
_type = 'CNAME'
|
||||
_value_type = CnameValue
|
||||
|
||||
@classmethod
|
||||
def validate(cls, name, fqdn, data):
|
||||
reasons = []
|
||||
if name == '':
|
||||
reasons.append('root CNAME not allowed')
|
||||
reasons.extend(super().validate(name, fqdn, data))
|
||||
return reasons
|
||||
|
||||
|
||||
Record.register_type(CnameRecord)
|
||||
@@ -0,0 +1,19 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from .base import Record, ValueMixin
|
||||
from .dynamic import _DynamicMixin
|
||||
from .target import _TargetValue
|
||||
|
||||
|
||||
class DnameValue(_TargetValue):
|
||||
pass
|
||||
|
||||
|
||||
class DnameRecord(_DynamicMixin, ValueMixin, Record):
|
||||
_type = 'DNAME'
|
||||
_value_type = DnameValue
|
||||
|
||||
|
||||
Record.register_type(DnameRecord)
|
||||
@@ -0,0 +1,136 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from ..equality import EqualityTupleMixin
|
||||
from .base import Record, ValuesMixin
|
||||
from .rr import RrParseError
|
||||
|
||||
|
||||
class DsValue(EqualityTupleMixin, dict):
|
||||
# https://www.rfc-editor.org/rfc/rfc4034.html#section-2.1
|
||||
|
||||
@classmethod
|
||||
def parse_rdata_text(cls, value):
|
||||
try:
|
||||
flags, protocol, algorithm, public_key = value.split(' ')
|
||||
except ValueError:
|
||||
raise RrParseError()
|
||||
try:
|
||||
flags = int(flags)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
protocol = int(protocol)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
algorithm = int(algorithm)
|
||||
except ValueError:
|
||||
pass
|
||||
return {
|
||||
'flags': flags,
|
||||
'protocol': protocol,
|
||||
'algorithm': algorithm,
|
||||
'public_key': public_key,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def validate(cls, data, _type):
|
||||
if not isinstance(data, (list, tuple)):
|
||||
data = (data,)
|
||||
reasons = []
|
||||
for value in data:
|
||||
try:
|
||||
int(value['flags'])
|
||||
except KeyError:
|
||||
reasons.append('missing flags')
|
||||
except ValueError:
|
||||
reasons.append(f'invalid flags "{value["flags"]}"')
|
||||
try:
|
||||
int(value['protocol'])
|
||||
except KeyError:
|
||||
reasons.append('missing protocol')
|
||||
except ValueError:
|
||||
reasons.append(f'invalid protocol "{value["protocol"]}"')
|
||||
try:
|
||||
int(value['algorithm'])
|
||||
except KeyError:
|
||||
reasons.append('missing algorithm')
|
||||
except ValueError:
|
||||
reasons.append(f'invalid algorithm "{value["algorithm"]}"')
|
||||
if 'public_key' not in value:
|
||||
reasons.append('missing public_key')
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def process(cls, values):
|
||||
return [cls(v) for v in values]
|
||||
|
||||
def __init__(self, value):
|
||||
super().__init__(
|
||||
{
|
||||
'flags': int(value['flags']),
|
||||
'protocol': int(value['protocol']),
|
||||
'algorithm': int(value['algorithm']),
|
||||
'public_key': value['public_key'],
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def flags(self):
|
||||
return self['flags']
|
||||
|
||||
@flags.setter
|
||||
def flags(self, value):
|
||||
self['flags'] = value
|
||||
|
||||
@property
|
||||
def protocol(self):
|
||||
return self['protocol']
|
||||
|
||||
@protocol.setter
|
||||
def protocol(self, value):
|
||||
self['protocol'] = value
|
||||
|
||||
@property
|
||||
def algorithm(self):
|
||||
return self['algorithm']
|
||||
|
||||
@algorithm.setter
|
||||
def algorithm(self, value):
|
||||
self['algorithm'] = value
|
||||
|
||||
@property
|
||||
def public_key(self):
|
||||
return self['public_key']
|
||||
|
||||
@public_key.setter
|
||||
def public_key(self, value):
|
||||
self['public_key'] = value
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def rdata_text(self):
|
||||
return (
|
||||
f'{self.flags} {self.protocol} {self.algorithm} {self.public_key}'
|
||||
)
|
||||
|
||||
def _equality_tuple(self):
|
||||
return (self.flags, self.protocol, self.algorithm, self.public_key)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f'{self.flags} {self.protocol} {self.algorithm} {self.public_key}'
|
||||
)
|
||||
|
||||
|
||||
class DsRecord(ValuesMixin, Record):
|
||||
_type = 'DS'
|
||||
_value_type = DsValue
|
||||
|
||||
|
||||
Record.register_type(DsRecord)
|
||||
@@ -0,0 +1,340 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from logging import getLogger
|
||||
import re
|
||||
|
||||
from .change import Update
|
||||
from .geo import GeoCodes
|
||||
|
||||
|
||||
class _DynamicPool(object):
|
||||
log = getLogger('_DynamicPool')
|
||||
|
||||
def __init__(self, _id, data, value_type):
|
||||
self._id = _id
|
||||
|
||||
values = [
|
||||
{
|
||||
'value': value_type(d['value']),
|
||||
'weight': d.get('weight', 1),
|
||||
'status': d.get('status', 'obey'),
|
||||
}
|
||||
for d in data['values']
|
||||
]
|
||||
values.sort(key=lambda d: d['value'])
|
||||
|
||||
# normalize weight of a single-value pool
|
||||
if len(values) == 1:
|
||||
weight = data['values'][0].get('weight', 1)
|
||||
if weight != 1:
|
||||
self.log.warning(
|
||||
'Using weight=1 instead of %s for single-value pool %s',
|
||||
weight,
|
||||
_id,
|
||||
)
|
||||
values[0]['weight'] = 1
|
||||
|
||||
fallback = data.get('fallback', None)
|
||||
self.data = {
|
||||
'fallback': fallback if fallback != 'default' else None,
|
||||
'values': values,
|
||||
}
|
||||
|
||||
def _data(self):
|
||||
return self.data
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, _DynamicPool):
|
||||
return False
|
||||
return self.data == other.data
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __repr__(self):
|
||||
return f'{self.data}'
|
||||
|
||||
|
||||
class _DynamicRule(object):
|
||||
def __init__(self, i, data):
|
||||
self.i = i
|
||||
|
||||
self.data = {}
|
||||
try:
|
||||
self.data['pool'] = data['pool']
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
self.data['geos'] = sorted(data['geos'])
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def _data(self):
|
||||
return self.data
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, _DynamicRule):
|
||||
return False
|
||||
return self.data == other.data
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __repr__(self):
|
||||
return f'{self.data}'
|
||||
|
||||
|
||||
class _Dynamic(object):
|
||||
def __init__(self, pools, rules):
|
||||
self.pools = pools
|
||||
self.rules = rules
|
||||
|
||||
def _data(self):
|
||||
pools = {}
|
||||
for _id, pool in self.pools.items():
|
||||
pools[_id] = pool._data()
|
||||
rules = []
|
||||
for rule in self.rules:
|
||||
rules.append(rule._data())
|
||||
return {'pools': pools, 'rules': rules}
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, _Dynamic):
|
||||
return False
|
||||
ret = self.pools == other.pools and self.rules == other.rules
|
||||
return ret
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __repr__(self):
|
||||
return f'{self.pools}, {self.rules}'
|
||||
|
||||
|
||||
class _DynamicMixin(object):
|
||||
geo_re = re.compile(
|
||||
r'^(?P<continent_code>\w\w)(-(?P<country_code>\w\w)'
|
||||
r'(-(?P<subdivision_code>\w\w))?)?$'
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate(cls, name, fqdn, data):
|
||||
reasons = super().validate(name, fqdn, data)
|
||||
|
||||
if 'dynamic' not in data:
|
||||
return reasons
|
||||
elif 'geo' in data:
|
||||
reasons.append('"dynamic" record with "geo" content')
|
||||
|
||||
try:
|
||||
pools = data['dynamic']['pools']
|
||||
except KeyError:
|
||||
pools = {}
|
||||
|
||||
pools_exist = set()
|
||||
pools_seen = set()
|
||||
pools_seen_as_fallback = set()
|
||||
if not isinstance(pools, dict):
|
||||
reasons.append('pools must be a dict')
|
||||
elif not pools:
|
||||
reasons.append('missing pools')
|
||||
else:
|
||||
for _id, pool in sorted(pools.items()):
|
||||
if not isinstance(pool, dict):
|
||||
reasons.append(f'pool "{_id}" must be a dict')
|
||||
continue
|
||||
try:
|
||||
values = pool['values']
|
||||
except KeyError:
|
||||
reasons.append(f'pool "{_id}" is missing values')
|
||||
continue
|
||||
|
||||
pools_exist.add(_id)
|
||||
|
||||
for i, value in enumerate(values):
|
||||
value_num = i + 1
|
||||
try:
|
||||
weight = value['weight']
|
||||
weight = int(weight)
|
||||
if weight < 1 or weight > 100:
|
||||
reasons.append(
|
||||
f'invalid weight "{weight}" in '
|
||||
f'pool "{_id}" value {value_num}'
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
except ValueError:
|
||||
reasons.append(
|
||||
f'invalid weight "{weight}" in '
|
||||
f'pool "{_id}" value {value_num}'
|
||||
)
|
||||
|
||||
try:
|
||||
status = value['status']
|
||||
if status not in ['up', 'down', 'obey']:
|
||||
reasons.append(
|
||||
f'invalid status "{status}" in '
|
||||
f'pool "{_id}" value {value_num}'
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
try:
|
||||
value = value['value']
|
||||
reasons.extend(
|
||||
cls._value_type.validate(value, cls._type)
|
||||
)
|
||||
except KeyError:
|
||||
reasons.append(
|
||||
f'missing value in pool "{_id}" '
|
||||
f'value {value_num}'
|
||||
)
|
||||
|
||||
if len(values) == 1 and values[0].get('weight', 1) != 1:
|
||||
reasons.append(
|
||||
f'pool "{_id}" has single value with weight!=1'
|
||||
)
|
||||
|
||||
fallback = pool.get('fallback', None)
|
||||
if fallback is not None:
|
||||
if fallback in pools:
|
||||
pools_seen_as_fallback.add(fallback)
|
||||
else:
|
||||
reasons.append(
|
||||
f'undefined fallback "{fallback}" '
|
||||
f'for pool "{_id}"'
|
||||
)
|
||||
|
||||
# Check for loops
|
||||
fallback = pools[_id].get('fallback', None)
|
||||
seen = [_id, fallback]
|
||||
while fallback is not None:
|
||||
# See if there's a next fallback
|
||||
fallback = pools.get(fallback, {}).get('fallback', None)
|
||||
if fallback in seen:
|
||||
loop = ' -> '.join(seen)
|
||||
reasons.append(f'loop in pool fallbacks: {loop}')
|
||||
# exit the loop
|
||||
break
|
||||
seen.append(fallback)
|
||||
|
||||
try:
|
||||
rules = data['dynamic']['rules']
|
||||
except KeyError:
|
||||
rules = []
|
||||
|
||||
if not isinstance(rules, (list, tuple)):
|
||||
reasons.append('rules must be a list')
|
||||
elif not rules:
|
||||
reasons.append('missing rules')
|
||||
else:
|
||||
seen_default = False
|
||||
|
||||
for i, rule in enumerate(rules):
|
||||
rule_num = i + 1
|
||||
try:
|
||||
pool = rule['pool']
|
||||
except KeyError:
|
||||
reasons.append(f'rule {rule_num} missing pool')
|
||||
continue
|
||||
|
||||
try:
|
||||
geos = rule['geos']
|
||||
except KeyError:
|
||||
geos = []
|
||||
|
||||
if not isinstance(pool, str):
|
||||
reasons.append(f'rule {rule_num} invalid pool "{pool}"')
|
||||
else:
|
||||
if pool not in pools:
|
||||
reasons.append(
|
||||
f'rule {rule_num} undefined pool ' f'"{pool}"'
|
||||
)
|
||||
elif pool in pools_seen and geos:
|
||||
reasons.append(
|
||||
f'rule {rule_num} invalid, target '
|
||||
f'pool "{pool}" reused'
|
||||
)
|
||||
pools_seen.add(pool)
|
||||
|
||||
if not geos:
|
||||
if seen_default:
|
||||
reasons.append(f'rule {rule_num} duplicate default')
|
||||
seen_default = True
|
||||
|
||||
if not isinstance(geos, (list, tuple)):
|
||||
reasons.append(f'rule {rule_num} geos must be a list')
|
||||
else:
|
||||
for geo in geos:
|
||||
reasons.extend(
|
||||
GeoCodes.validate(geo, f'rule {rule_num} ')
|
||||
)
|
||||
|
||||
unused = pools_exist - pools_seen - pools_seen_as_fallback
|
||||
if unused:
|
||||
unused = '", "'.join(sorted(unused))
|
||||
reasons.append(f'unused pools: "{unused}"')
|
||||
|
||||
return reasons
|
||||
|
||||
def __init__(self, zone, name, data, *args, **kwargs):
|
||||
super().__init__(zone, name, data, *args, **kwargs)
|
||||
|
||||
self.dynamic = {}
|
||||
|
||||
if 'dynamic' not in data:
|
||||
return
|
||||
|
||||
# pools
|
||||
try:
|
||||
pools = dict(data['dynamic']['pools'])
|
||||
except:
|
||||
pools = {}
|
||||
|
||||
for _id, pool in sorted(pools.items()):
|
||||
pools[_id] = _DynamicPool(_id, pool, self._value_type)
|
||||
|
||||
# rules
|
||||
try:
|
||||
rules = list(data['dynamic']['rules'])
|
||||
except:
|
||||
rules = []
|
||||
|
||||
parsed = []
|
||||
for i, rule in enumerate(rules):
|
||||
parsed.append(_DynamicRule(i, rule))
|
||||
|
||||
# dynamic
|
||||
self.dynamic = _Dynamic(pools, parsed)
|
||||
|
||||
def _data(self):
|
||||
ret = super()._data()
|
||||
if self.dynamic:
|
||||
ret['dynamic'] = self.dynamic._data()
|
||||
return ret
|
||||
|
||||
def changes(self, other, target):
|
||||
if target.SUPPORTS_DYNAMIC:
|
||||
if self.dynamic != other.dynamic:
|
||||
return Update(self, other)
|
||||
return super().changes(other, target)
|
||||
|
||||
def __repr__(self):
|
||||
# TODO: improve this whole thing, we need multi-line...
|
||||
if self.dynamic:
|
||||
# TODO: this hack can't going to cut it, as part of said
|
||||
# improvements the value types should deal with serializing their
|
||||
# value
|
||||
try:
|
||||
values = self.values
|
||||
except AttributeError:
|
||||
values = self.value
|
||||
|
||||
klass = self.__class__.__name__
|
||||
return (
|
||||
f'<{klass} {self._type} {self.ttl}, {self.decoded_fqdn}, '
|
||||
f'{values}, {self.dynamic}>'
|
||||
)
|
||||
return super().__repr__()
|
||||
@@ -0,0 +1,21 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from ..idna import idna_decode
|
||||
|
||||
|
||||
class RecordException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(RecordException):
|
||||
@classmethod
|
||||
def build_message(cls, fqdn, reasons):
|
||||
reasons = '\n - '.join(reasons)
|
||||
return f'Invalid record {idna_decode(fqdn)}\n - {reasons}'
|
||||
|
||||
def __init__(self, fqdn, reasons):
|
||||
super().__init__(self.build_message(fqdn, reasons))
|
||||
self.fqdn = fqdn
|
||||
self.reasons = reasons
|
||||
@@ -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__()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
|
||||
class _IpValue(str):
|
||||
@classmethod
|
||||
def parse_rdata_text(cls, value):
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def validate(cls, data, _type):
|
||||
if not isinstance(data, (list, tuple)):
|
||||
data = (data,)
|
||||
if len(data) == 0:
|
||||
return ['missing value(s)']
|
||||
reasons = []
|
||||
for value in data:
|
||||
if value == '':
|
||||
reasons.append('empty value')
|
||||
elif value is None:
|
||||
reasons.append('missing value(s)')
|
||||
else:
|
||||
try:
|
||||
cls._address_type(str(value))
|
||||
except Exception:
|
||||
addr_name = cls._address_name
|
||||
reasons.append(f'invalid {addr_name} address "{value}"')
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def process(cls, values):
|
||||
# Translating None into '' so that the list will be sortable in
|
||||
# python3, get everything to str first
|
||||
values = [v if v is not None else '' for v in values]
|
||||
# Now round trip all non-'' through the address type and back to a str
|
||||
# to normalize the address representation.
|
||||
return [cls(v) if v != '' else '' for v in values]
|
||||
|
||||
def __new__(cls, v):
|
||||
v = str(cls._address_type(v))
|
||||
return super().__new__(cls, v)
|
||||
|
||||
@property
|
||||
def rdata_text(self):
|
||||
return self
|
||||
|
||||
|
||||
_IpAddress = _IpValue
|
||||
@@ -0,0 +1,358 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from ..equality import EqualityTupleMixin
|
||||
from .base import Record, ValuesMixin
|
||||
from .rr import RrParseError
|
||||
|
||||
|
||||
class LocValue(EqualityTupleMixin, dict):
|
||||
# TODO: this does not really match the RFC, but it's stuck using the details
|
||||
# of how the type was impelemented. Would be nice to rework things to match
|
||||
# while maintaining backwards compatibility.
|
||||
# https://www.rfc-editor.org/rfc/rfc1876.html
|
||||
|
||||
@classmethod
|
||||
def parse_rdata_text(cls, value):
|
||||
try:
|
||||
value = value.replace('m', '')
|
||||
(
|
||||
lat_degrees,
|
||||
lat_minutes,
|
||||
lat_seconds,
|
||||
lat_direction,
|
||||
long_degrees,
|
||||
long_minutes,
|
||||
long_seconds,
|
||||
long_direction,
|
||||
altitude,
|
||||
size,
|
||||
precision_horz,
|
||||
precision_vert,
|
||||
) = value.split(' ')
|
||||
except ValueError:
|
||||
raise RrParseError()
|
||||
try:
|
||||
lat_degrees = int(lat_degrees)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
lat_minutes = int(lat_minutes)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
long_degrees = int(long_degrees)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
long_minutes = int(long_minutes)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
lat_seconds = float(lat_seconds)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
long_seconds = float(long_seconds)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
altitude = float(altitude)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
size = float(size)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
precision_horz = float(precision_horz)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
precision_vert = float(precision_vert)
|
||||
except ValueError:
|
||||
pass
|
||||
return {
|
||||
'lat_degrees': lat_degrees,
|
||||
'lat_minutes': lat_minutes,
|
||||
'lat_seconds': lat_seconds,
|
||||
'lat_direction': lat_direction,
|
||||
'long_degrees': long_degrees,
|
||||
'long_minutes': long_minutes,
|
||||
'long_seconds': long_seconds,
|
||||
'long_direction': long_direction,
|
||||
'altitude': altitude,
|
||||
'size': size,
|
||||
'precision_horz': precision_horz,
|
||||
'precision_vert': precision_vert,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def validate(cls, data, _type):
|
||||
int_keys = [
|
||||
'lat_degrees',
|
||||
'lat_minutes',
|
||||
'long_degrees',
|
||||
'long_minutes',
|
||||
]
|
||||
|
||||
float_keys = [
|
||||
'lat_seconds',
|
||||
'long_seconds',
|
||||
'altitude',
|
||||
'size',
|
||||
'precision_horz',
|
||||
'precision_vert',
|
||||
]
|
||||
|
||||
direction_keys = ['lat_direction', 'long_direction']
|
||||
|
||||
if not isinstance(data, (list, tuple)):
|
||||
data = (data,)
|
||||
reasons = []
|
||||
for value in data:
|
||||
for key in int_keys:
|
||||
try:
|
||||
int(value[key])
|
||||
if (
|
||||
(
|
||||
key == 'lat_degrees'
|
||||
and not 0 <= int(value[key]) <= 90
|
||||
)
|
||||
or (
|
||||
key == 'long_degrees'
|
||||
and not 0 <= int(value[key]) <= 180
|
||||
)
|
||||
or (
|
||||
key in ['lat_minutes', 'long_minutes']
|
||||
and not 0 <= int(value[key]) <= 59
|
||||
)
|
||||
):
|
||||
reasons.append(
|
||||
f'invalid value for {key} ' f'"{value[key]}"'
|
||||
)
|
||||
except KeyError:
|
||||
reasons.append(f'missing {key}')
|
||||
except ValueError:
|
||||
reasons.append(f'invalid {key} "{value[key]}"')
|
||||
|
||||
for key in float_keys:
|
||||
try:
|
||||
float(value[key])
|
||||
if (
|
||||
(
|
||||
key in ['lat_seconds', 'long_seconds']
|
||||
and not 0 <= float(value[key]) <= 59.999
|
||||
)
|
||||
or (
|
||||
key == 'altitude'
|
||||
and not -100000.00
|
||||
<= float(value[key])
|
||||
<= 42849672.95
|
||||
)
|
||||
or (
|
||||
key in ['size', 'precision_horz', 'precision_vert']
|
||||
and not 0 <= float(value[key]) <= 90000000.00
|
||||
)
|
||||
):
|
||||
reasons.append(
|
||||
f'invalid value for {key} ' f'"{value[key]}"'
|
||||
)
|
||||
except KeyError:
|
||||
reasons.append(f'missing {key}')
|
||||
except ValueError:
|
||||
reasons.append(f'invalid {key} "{value[key]}"')
|
||||
|
||||
for key in direction_keys:
|
||||
try:
|
||||
str(value[key])
|
||||
if key == 'lat_direction' and value[key] not in ['N', 'S']:
|
||||
reasons.append(
|
||||
f'invalid direction for {key} ' f'"{value[key]}"'
|
||||
)
|
||||
if key == 'long_direction' and value[key] not in ['E', 'W']:
|
||||
reasons.append(
|
||||
f'invalid direction for {key} ' f'"{value[key]}"'
|
||||
)
|
||||
except KeyError:
|
||||
reasons.append(f'missing {key}')
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def process(cls, values):
|
||||
return [cls(v) for v in values]
|
||||
|
||||
def __init__(self, value):
|
||||
super().__init__(
|
||||
{
|
||||
'lat_degrees': int(value['lat_degrees']),
|
||||
'lat_minutes': int(value['lat_minutes']),
|
||||
'lat_seconds': float(value['lat_seconds']),
|
||||
'lat_direction': value['lat_direction'].upper(),
|
||||
'long_degrees': int(value['long_degrees']),
|
||||
'long_minutes': int(value['long_minutes']),
|
||||
'long_seconds': float(value['long_seconds']),
|
||||
'long_direction': value['long_direction'].upper(),
|
||||
'altitude': float(value['altitude']),
|
||||
'size': float(value['size']),
|
||||
'precision_horz': float(value['precision_horz']),
|
||||
'precision_vert': float(value['precision_vert']),
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def lat_degrees(self):
|
||||
return self['lat_degrees']
|
||||
|
||||
@lat_degrees.setter
|
||||
def lat_degrees(self, value):
|
||||
self['lat_degrees'] = value
|
||||
|
||||
@property
|
||||
def lat_minutes(self):
|
||||
return self['lat_minutes']
|
||||
|
||||
@lat_minutes.setter
|
||||
def lat_minutes(self, value):
|
||||
self['lat_minutes'] = value
|
||||
|
||||
@property
|
||||
def lat_seconds(self):
|
||||
return self['lat_seconds']
|
||||
|
||||
@lat_seconds.setter
|
||||
def lat_seconds(self, value):
|
||||
self['lat_seconds'] = value
|
||||
|
||||
@property
|
||||
def lat_direction(self):
|
||||
return self['lat_direction']
|
||||
|
||||
@lat_direction.setter
|
||||
def lat_direction(self, value):
|
||||
self['lat_direction'] = value
|
||||
|
||||
@property
|
||||
def long_degrees(self):
|
||||
return self['long_degrees']
|
||||
|
||||
@long_degrees.setter
|
||||
def long_degrees(self, value):
|
||||
self['long_degrees'] = value
|
||||
|
||||
@property
|
||||
def long_minutes(self):
|
||||
return self['long_minutes']
|
||||
|
||||
@long_minutes.setter
|
||||
def long_minutes(self, value):
|
||||
self['long_minutes'] = value
|
||||
|
||||
@property
|
||||
def long_seconds(self):
|
||||
return self['long_seconds']
|
||||
|
||||
@long_seconds.setter
|
||||
def long_seconds(self, value):
|
||||
self['long_seconds'] = value
|
||||
|
||||
@property
|
||||
def long_direction(self):
|
||||
return self['long_direction']
|
||||
|
||||
@long_direction.setter
|
||||
def long_direction(self, value):
|
||||
self['long_direction'] = value
|
||||
|
||||
@property
|
||||
def altitude(self):
|
||||
return self['altitude']
|
||||
|
||||
@altitude.setter
|
||||
def altitude(self, value):
|
||||
self['altitude'] = value
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return self['size']
|
||||
|
||||
@size.setter
|
||||
def size(self, value):
|
||||
self['size'] = value
|
||||
|
||||
@property
|
||||
def precision_horz(self):
|
||||
return self['precision_horz']
|
||||
|
||||
@precision_horz.setter
|
||||
def precision_horz(self, value):
|
||||
self['precision_horz'] = value
|
||||
|
||||
@property
|
||||
def precision_vert(self):
|
||||
return self['precision_vert']
|
||||
|
||||
@precision_vert.setter
|
||||
def precision_vert(self, value):
|
||||
self['precision_vert'] = value
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def rdata_text(self):
|
||||
return f'{self.lat_degrees} {self.lat_minutes} {self.lat_seconds} {self.lat_direction} {self.long_degrees} {self.long_minutes} {self.long_seconds} {self.long_direction} {self.altitude}m {self.size}m {self.precision_horz}m {self.precision_vert}m'
|
||||
|
||||
def __hash__(self):
|
||||
return hash(
|
||||
(
|
||||
self.lat_degrees,
|
||||
self.lat_minutes,
|
||||
self.lat_seconds,
|
||||
self.lat_direction,
|
||||
self.long_degrees,
|
||||
self.long_minutes,
|
||||
self.long_seconds,
|
||||
self.long_direction,
|
||||
self.altitude,
|
||||
self.size,
|
||||
self.precision_horz,
|
||||
self.precision_vert,
|
||||
)
|
||||
)
|
||||
|
||||
def _equality_tuple(self):
|
||||
return (
|
||||
self.lat_degrees,
|
||||
self.lat_minutes,
|
||||
self.lat_seconds,
|
||||
self.lat_direction,
|
||||
self.long_degrees,
|
||||
self.long_minutes,
|
||||
self.long_seconds,
|
||||
self.long_direction,
|
||||
self.altitude,
|
||||
self.size,
|
||||
self.precision_horz,
|
||||
self.precision_vert,
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"'{self.lat_degrees} {self.lat_minutes} "
|
||||
f"{self.lat_seconds:.3f} {self.lat_direction} "
|
||||
f"{self.long_degrees} {self.long_minutes} "
|
||||
f"{self.long_seconds:.3f} {self.long_direction} "
|
||||
f"{self.altitude:.2f}m {self.size:.2f}m "
|
||||
f"{self.precision_horz:.2f}m {self.precision_vert:.2f}m'"
|
||||
)
|
||||
|
||||
|
||||
class LocRecord(ValuesMixin, Record):
|
||||
_type = 'LOC'
|
||||
_value_type = LocValue
|
||||
|
||||
|
||||
Record.register_type(LocRecord)
|
||||
@@ -0,0 +1,120 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from fqdn import FQDN
|
||||
|
||||
from ..equality import EqualityTupleMixin
|
||||
from ..idna import idna_encode
|
||||
from .base import Record, ValuesMixin
|
||||
from .rr import RrParseError
|
||||
|
||||
|
||||
class MxValue(EqualityTupleMixin, dict):
|
||||
@classmethod
|
||||
def parse_rdata_text(cls, value):
|
||||
try:
|
||||
preference, exchange = value.split(' ')
|
||||
except ValueError:
|
||||
raise RrParseError()
|
||||
try:
|
||||
preference = int(preference)
|
||||
except ValueError:
|
||||
pass
|
||||
return {'preference': preference, 'exchange': exchange}
|
||||
|
||||
@classmethod
|
||||
def validate(cls, data, _type):
|
||||
if not isinstance(data, (list, tuple)):
|
||||
data = (data,)
|
||||
reasons = []
|
||||
for value in data:
|
||||
try:
|
||||
try:
|
||||
int(value['preference'])
|
||||
except KeyError:
|
||||
int(value['priority'])
|
||||
except KeyError:
|
||||
reasons.append('missing preference')
|
||||
except ValueError:
|
||||
reasons.append(f'invalid preference "{value["preference"]}"')
|
||||
exchange = None
|
||||
try:
|
||||
exchange = value.get('exchange', None) or value['value']
|
||||
if not exchange:
|
||||
reasons.append('missing exchange')
|
||||
continue
|
||||
exchange = idna_encode(exchange)
|
||||
if (
|
||||
exchange != '.'
|
||||
and not FQDN(exchange, allow_underscores=True).is_valid
|
||||
):
|
||||
reasons.append(
|
||||
f'Invalid MX exchange "{exchange}" is not '
|
||||
'a valid FQDN.'
|
||||
)
|
||||
elif not exchange.endswith('.'):
|
||||
reasons.append(f'MX value "{exchange}" missing trailing .')
|
||||
except KeyError:
|
||||
reasons.append('missing exchange')
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def process(cls, values):
|
||||
return [cls(v) for v in values]
|
||||
|
||||
def __init__(self, value):
|
||||
# RFC1035 says preference, half the providers use priority
|
||||
try:
|
||||
preference = value['preference']
|
||||
except KeyError:
|
||||
preference = value['priority']
|
||||
# UNTIL 1.0 remove value fallback
|
||||
try:
|
||||
exchange = value['exchange']
|
||||
except KeyError:
|
||||
exchange = value['value']
|
||||
super().__init__(
|
||||
{'preference': int(preference), 'exchange': idna_encode(exchange)}
|
||||
)
|
||||
|
||||
@property
|
||||
def preference(self):
|
||||
return self['preference']
|
||||
|
||||
@preference.setter
|
||||
def preference(self, value):
|
||||
self['preference'] = value
|
||||
|
||||
@property
|
||||
def exchange(self):
|
||||
return self['exchange']
|
||||
|
||||
@exchange.setter
|
||||
def exchange(self, value):
|
||||
self['exchange'] = value
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def rdata_text(self):
|
||||
return f'{self.preference} {self.exchange}'
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.preference, self.exchange))
|
||||
|
||||
def _equality_tuple(self):
|
||||
return (self.preference, self.exchange)
|
||||
|
||||
def __repr__(self):
|
||||
return f"'{self.preference} {self.exchange}'"
|
||||
|
||||
|
||||
class MxRecord(ValuesMixin, Record):
|
||||
_type = 'MX'
|
||||
_value_type = MxValue
|
||||
|
||||
|
||||
Record.register_type(MxRecord)
|
||||
@@ -0,0 +1,172 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from ..equality import EqualityTupleMixin
|
||||
from .base import Record, ValuesMixin
|
||||
from .rr import RrParseError
|
||||
|
||||
|
||||
class NaptrValue(EqualityTupleMixin, dict):
|
||||
VALID_FLAGS = ('S', 'A', 'U', 'P')
|
||||
|
||||
@classmethod
|
||||
def parse_rdata_text(cls, value):
|
||||
try:
|
||||
(
|
||||
order,
|
||||
preference,
|
||||
flags,
|
||||
service,
|
||||
regexp,
|
||||
replacement,
|
||||
) = value.split(' ')
|
||||
except ValueError:
|
||||
raise RrParseError()
|
||||
try:
|
||||
order = int(order)
|
||||
preference = int(preference)
|
||||
except ValueError:
|
||||
pass
|
||||
return {
|
||||
'order': order,
|
||||
'preference': preference,
|
||||
'flags': flags,
|
||||
'service': service,
|
||||
'regexp': regexp,
|
||||
'replacement': replacement,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def validate(cls, data, _type):
|
||||
if not isinstance(data, (list, tuple)):
|
||||
data = (data,)
|
||||
reasons = []
|
||||
for value in data:
|
||||
try:
|
||||
int(value['order'])
|
||||
except KeyError:
|
||||
reasons.append('missing order')
|
||||
except ValueError:
|
||||
reasons.append(f'invalid order "{value["order"]}"')
|
||||
try:
|
||||
int(value['preference'])
|
||||
except KeyError:
|
||||
reasons.append('missing preference')
|
||||
except ValueError:
|
||||
reasons.append(f'invalid preference "{value["preference"]}"')
|
||||
try:
|
||||
flags = value['flags']
|
||||
if flags not in cls.VALID_FLAGS:
|
||||
reasons.append(f'unrecognized flags "{flags}"')
|
||||
except KeyError:
|
||||
reasons.append('missing flags')
|
||||
|
||||
# TODO: validate these... they're non-trivial
|
||||
for k in ('service', 'regexp', 'replacement'):
|
||||
if k not in value:
|
||||
reasons.append(f'missing {k}')
|
||||
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def process(cls, values):
|
||||
return [cls(v) for v in values]
|
||||
|
||||
def __init__(self, value):
|
||||
super().__init__(
|
||||
{
|
||||
'order': int(value['order']),
|
||||
'preference': int(value['preference']),
|
||||
'flags': value['flags'],
|
||||
'service': value['service'],
|
||||
'regexp': value['regexp'],
|
||||
'replacement': value['replacement'],
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def order(self):
|
||||
return self['order']
|
||||
|
||||
@order.setter
|
||||
def order(self, value):
|
||||
self['order'] = value
|
||||
|
||||
@property
|
||||
def preference(self):
|
||||
return self['preference']
|
||||
|
||||
@preference.setter
|
||||
def preference(self, value):
|
||||
self['preference'] = value
|
||||
|
||||
@property
|
||||
def flags(self):
|
||||
return self['flags']
|
||||
|
||||
@flags.setter
|
||||
def flags(self, value):
|
||||
self['flags'] = value
|
||||
|
||||
@property
|
||||
def service(self):
|
||||
return self['service']
|
||||
|
||||
@service.setter
|
||||
def service(self, value):
|
||||
self['service'] = value
|
||||
|
||||
@property
|
||||
def regexp(self):
|
||||
return self['regexp']
|
||||
|
||||
@regexp.setter
|
||||
def regexp(self, value):
|
||||
self['regexp'] = value
|
||||
|
||||
@property
|
||||
def replacement(self):
|
||||
return self['replacement']
|
||||
|
||||
@replacement.setter
|
||||
def replacement(self, value):
|
||||
self['replacement'] = value
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def rdata_text(self):
|
||||
return f'{self.order} {self.preference} {self.flags} {self.service} {self.regexp} {self.replacement}'
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.__repr__())
|
||||
|
||||
def _equality_tuple(self):
|
||||
return (
|
||||
self.order,
|
||||
self.preference,
|
||||
self.flags,
|
||||
self.service,
|
||||
self.regexp,
|
||||
self.replacement,
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
flags = self.flags if self.flags is not None else ''
|
||||
service = self.service if self.service is not None else ''
|
||||
regexp = self.regexp if self.regexp is not None else ''
|
||||
return (
|
||||
f"'{self.order} {self.preference} \"{flags}\" \"{service}\" "
|
||||
f"\"{regexp}\" {self.replacement}'"
|
||||
)
|
||||
|
||||
|
||||
class NaptrRecord(ValuesMixin, Record):
|
||||
_type = 'NAPTR'
|
||||
_value_type = NaptrValue
|
||||
|
||||
|
||||
Record.register_type(NaptrRecord)
|
||||
@@ -0,0 +1,18 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from .base import Record, ValuesMixin
|
||||
from .target import _TargetsValue
|
||||
|
||||
|
||||
class NsValue(_TargetsValue):
|
||||
pass
|
||||
|
||||
|
||||
class NsRecord(ValuesMixin, Record):
|
||||
_type = 'NS'
|
||||
_value_type = NsValue
|
||||
|
||||
|
||||
Record.register_type(NsRecord)
|
||||
@@ -0,0 +1,24 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from .base import Record, ValuesMixin
|
||||
from .target import _TargetsValue
|
||||
|
||||
|
||||
class PtrValue(_TargetsValue):
|
||||
pass
|
||||
|
||||
|
||||
class PtrRecord(ValuesMixin, Record):
|
||||
_type = 'PTR'
|
||||
_value_type = PtrValue
|
||||
|
||||
# This is for backward compatibility with providers that don't support
|
||||
# multi-value PTR records.
|
||||
@property
|
||||
def value(self):
|
||||
return self.values[0]
|
||||
|
||||
|
||||
Record.register_type(PtrRecord)
|
||||
@@ -0,0 +1,27 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from .exception import RecordException
|
||||
|
||||
|
||||
class RrParseError(RecordException):
|
||||
def __init__(self, message='failed to parse string value as RR text'):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class Rr(object):
|
||||
'''
|
||||
Simple object intended to be used with Record.from_rrs to allow providers
|
||||
that work with RFC formatted rdata to share centralized parsing/encoding
|
||||
code
|
||||
'''
|
||||
|
||||
def __init__(self, name, _type, ttl, rdata):
|
||||
self.name = name
|
||||
self._type = _type
|
||||
self.ttl = ttl
|
||||
self.rdata = rdata
|
||||
|
||||
def __repr__(self):
|
||||
return f'Rr<{self.name}, {self._type}, {self.ttl}, {self.rdata}'
|
||||
@@ -0,0 +1,14 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from .base import Record
|
||||
from .chunked import _ChunkedValue, _ChunkedValuesMixin
|
||||
|
||||
|
||||
class SpfRecord(_ChunkedValuesMixin, Record):
|
||||
_type = 'SPF'
|
||||
_value_type = _ChunkedValue
|
||||
|
||||
|
||||
Record.register_type(SpfRecord)
|
||||
@@ -0,0 +1,158 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from fqdn import FQDN
|
||||
import re
|
||||
|
||||
from ..equality import EqualityTupleMixin
|
||||
from ..idna import idna_encode
|
||||
from .base import Record, ValuesMixin
|
||||
from .rr import RrParseError
|
||||
|
||||
|
||||
class SrvValue(EqualityTupleMixin, dict):
|
||||
@classmethod
|
||||
def parse_rdata_text(self, value):
|
||||
try:
|
||||
priority, weight, port, target = value.split(' ')
|
||||
except ValueError:
|
||||
raise RrParseError()
|
||||
try:
|
||||
priority = int(priority)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
weight = int(weight)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
port = int(port)
|
||||
except ValueError:
|
||||
pass
|
||||
return {
|
||||
'priority': priority,
|
||||
'weight': weight,
|
||||
'port': port,
|
||||
'target': target,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def validate(cls, data, _type):
|
||||
if not isinstance(data, (list, tuple)):
|
||||
data = (data,)
|
||||
reasons = []
|
||||
for value in data:
|
||||
# TODO: validate algorithm and fingerprint_type values
|
||||
try:
|
||||
int(value['priority'])
|
||||
except KeyError:
|
||||
reasons.append('missing priority')
|
||||
except ValueError:
|
||||
reasons.append(f'invalid priority "{value["priority"]}"')
|
||||
try:
|
||||
int(value['weight'])
|
||||
except KeyError:
|
||||
reasons.append('missing weight')
|
||||
except ValueError:
|
||||
reasons.append(f'invalid weight "{value["weight"]}"')
|
||||
try:
|
||||
int(value['port'])
|
||||
except KeyError:
|
||||
reasons.append('missing port')
|
||||
except ValueError:
|
||||
reasons.append(f'invalid port "{value["port"]}"')
|
||||
try:
|
||||
target = value['target']
|
||||
if not target:
|
||||
reasons.append('missing target')
|
||||
continue
|
||||
target = idna_encode(target)
|
||||
if not target.endswith('.'):
|
||||
reasons.append(f'SRV value "{target}" missing trailing .')
|
||||
if (
|
||||
target != '.'
|
||||
and not FQDN(target, allow_underscores=True).is_valid
|
||||
):
|
||||
reasons.append(
|
||||
f'Invalid SRV target "{target}" is not a valid FQDN.'
|
||||
)
|
||||
except KeyError:
|
||||
reasons.append('missing target')
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def process(cls, values):
|
||||
return [cls(v) for v in values]
|
||||
|
||||
def __init__(self, value):
|
||||
super().__init__(
|
||||
{
|
||||
'priority': int(value['priority']),
|
||||
'weight': int(value['weight']),
|
||||
'port': int(value['port']),
|
||||
'target': idna_encode(value['target']),
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def priority(self):
|
||||
return self['priority']
|
||||
|
||||
@priority.setter
|
||||
def priority(self, value):
|
||||
self['priority'] = value
|
||||
|
||||
@property
|
||||
def weight(self):
|
||||
return self['weight']
|
||||
|
||||
@weight.setter
|
||||
def weight(self, value):
|
||||
self['weight'] = value
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
return self['port']
|
||||
|
||||
@port.setter
|
||||
def port(self, value):
|
||||
self['port'] = value
|
||||
|
||||
@property
|
||||
def target(self):
|
||||
return self['target']
|
||||
|
||||
@target.setter
|
||||
def target(self, value):
|
||||
self['target'] = value
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.__repr__())
|
||||
|
||||
def _equality_tuple(self):
|
||||
return (self.priority, self.weight, self.port, self.target)
|
||||
|
||||
def __repr__(self):
|
||||
return f"'{self.priority} {self.weight} {self.port} {self.target}'"
|
||||
|
||||
|
||||
class SrvRecord(ValuesMixin, Record):
|
||||
_type = 'SRV'
|
||||
_value_type = SrvValue
|
||||
_name_re = re.compile(r'^(\*|_[^\.]+)\.[^\.]+')
|
||||
|
||||
@classmethod
|
||||
def validate(cls, name, fqdn, data):
|
||||
reasons = []
|
||||
if not cls._name_re.match(name):
|
||||
reasons.append('invalid name for SRV record')
|
||||
reasons.extend(super().validate(name, fqdn, data))
|
||||
return reasons
|
||||
|
||||
|
||||
Record.register_type(SrvRecord)
|
||||
@@ -0,0 +1,124 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from ..equality import EqualityTupleMixin
|
||||
from .base import Record, ValuesMixin
|
||||
from .rr import RrParseError
|
||||
|
||||
|
||||
class SshfpValue(EqualityTupleMixin, dict):
|
||||
VALID_ALGORITHMS = (1, 2, 3, 4)
|
||||
VALID_FINGERPRINT_TYPES = (1, 2)
|
||||
|
||||
@classmethod
|
||||
def parse_rdata_text(self, value):
|
||||
try:
|
||||
algorithm, fingerprint_type, fingerprint = value.split(' ')
|
||||
except ValueError:
|
||||
raise RrParseError()
|
||||
try:
|
||||
algorithm = int(algorithm)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
fingerprint_type = int(fingerprint_type)
|
||||
except ValueError:
|
||||
pass
|
||||
return {
|
||||
'algorithm': algorithm,
|
||||
'fingerprint_type': fingerprint_type,
|
||||
'fingerprint': fingerprint,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def validate(cls, data, _type):
|
||||
if not isinstance(data, (list, tuple)):
|
||||
data = (data,)
|
||||
reasons = []
|
||||
for value in data:
|
||||
try:
|
||||
algorithm = int(value['algorithm'])
|
||||
if algorithm not in cls.VALID_ALGORITHMS:
|
||||
reasons.append(f'unrecognized algorithm "{algorithm}"')
|
||||
except KeyError:
|
||||
reasons.append('missing algorithm')
|
||||
except ValueError:
|
||||
reasons.append(f'invalid algorithm "{value["algorithm"]}"')
|
||||
try:
|
||||
fingerprint_type = int(value['fingerprint_type'])
|
||||
if fingerprint_type not in cls.VALID_FINGERPRINT_TYPES:
|
||||
reasons.append(
|
||||
'unrecognized fingerprint_type ' f'"{fingerprint_type}"'
|
||||
)
|
||||
except KeyError:
|
||||
reasons.append('missing fingerprint_type')
|
||||
except ValueError:
|
||||
reasons.append(
|
||||
'invalid fingerprint_type ' f'"{value["fingerprint_type"]}"'
|
||||
)
|
||||
if 'fingerprint' not in value:
|
||||
reasons.append('missing fingerprint')
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def process(cls, values):
|
||||
return [cls(v) for v in values]
|
||||
|
||||
def __init__(self, value):
|
||||
super().__init__(
|
||||
{
|
||||
'algorithm': int(value['algorithm']),
|
||||
'fingerprint_type': int(value['fingerprint_type']),
|
||||
'fingerprint': value['fingerprint'],
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def algorithm(self):
|
||||
return self['algorithm']
|
||||
|
||||
@algorithm.setter
|
||||
def algorithm(self, value):
|
||||
self['algorithm'] = value
|
||||
|
||||
@property
|
||||
def fingerprint_type(self):
|
||||
return self['fingerprint_type']
|
||||
|
||||
@fingerprint_type.setter
|
||||
def fingerprint_type(self, value):
|
||||
self['fingerprint_type'] = value
|
||||
|
||||
@property
|
||||
def fingerprint(self):
|
||||
return self['fingerprint']
|
||||
|
||||
@fingerprint.setter
|
||||
def fingerprint(self, value):
|
||||
self['fingerprint'] = value
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def rdata_text(self):
|
||||
return f'{self.algorithm} {self.fingerprint_type} {self.fingerprint}'
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.__repr__())
|
||||
|
||||
def _equality_tuple(self):
|
||||
return (self.algorithm, self.fingerprint_type, self.fingerprint)
|
||||
|
||||
def __repr__(self):
|
||||
return f"'{self.algorithm} {self.fingerprint_type} {self.fingerprint}'"
|
||||
|
||||
|
||||
class SshfpRecord(ValuesMixin, Record):
|
||||
_type = 'SSHFP'
|
||||
_value_type = SshfpValue
|
||||
|
||||
|
||||
Record.register_type(SshfpRecord)
|
||||
@@ -0,0 +1,79 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from fqdn import FQDN
|
||||
|
||||
from ..idna import idna_encode
|
||||
|
||||
|
||||
class _TargetValue(str):
|
||||
@classmethod
|
||||
def parse_rdata_text(self, value):
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def validate(cls, data, _type):
|
||||
reasons = []
|
||||
if data == '':
|
||||
reasons.append('empty value')
|
||||
elif not data:
|
||||
reasons.append('missing value')
|
||||
else:
|
||||
data = idna_encode(data)
|
||||
if not FQDN(str(data), allow_underscores=True).is_valid:
|
||||
reasons.append(f'{_type} value "{data}" is not a valid FQDN')
|
||||
elif not data.endswith('.'):
|
||||
reasons.append(f'{_type} value "{data}" missing trailing .')
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def process(cls, value):
|
||||
if value:
|
||||
return cls(value)
|
||||
return None
|
||||
|
||||
def __new__(cls, v):
|
||||
v = idna_encode(v)
|
||||
return super().__new__(cls, v)
|
||||
|
||||
@property
|
||||
def rdata_text(self):
|
||||
return self
|
||||
|
||||
|
||||
#
|
||||
# much like _TargetValue, but geared towards multiple values
|
||||
class _TargetsValue(str):
|
||||
@classmethod
|
||||
def parse_rdata_text(cls, value):
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def validate(cls, data, _type):
|
||||
if not data:
|
||||
return ['missing value(s)']
|
||||
elif not isinstance(data, (list, tuple)):
|
||||
data = (data,)
|
||||
reasons = []
|
||||
for value in data:
|
||||
value = idna_encode(value)
|
||||
if not FQDN(value, allow_underscores=True).is_valid:
|
||||
reasons.append(
|
||||
f'Invalid {_type} value "{value}" is not a valid FQDN.'
|
||||
)
|
||||
elif not value.endswith('.'):
|
||||
reasons.append(f'{_type} value "{value}" missing trailing .')
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def process(cls, values):
|
||||
return [cls(v) for v in values]
|
||||
|
||||
def __new__(cls, v):
|
||||
v = idna_encode(v)
|
||||
return super().__new__(cls, v)
|
||||
|
||||
@property
|
||||
def rdata_text(self):
|
||||
return self
|
||||
@@ -0,0 +1,160 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from ..equality import EqualityTupleMixin
|
||||
from .base import Record, ValuesMixin
|
||||
from .rr import RrParseError
|
||||
|
||||
|
||||
class TlsaValue(EqualityTupleMixin, dict):
|
||||
@classmethod
|
||||
def parse_rdata_text(self, value):
|
||||
try:
|
||||
(
|
||||
certificate_usage,
|
||||
selector,
|
||||
matching_type,
|
||||
certificate_association_data,
|
||||
) = value.split(' ')
|
||||
except ValueError:
|
||||
raise RrParseError()
|
||||
try:
|
||||
certificate_usage = int(certificate_usage)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
selector = int(selector)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
matching_type = int(matching_type)
|
||||
except ValueError:
|
||||
pass
|
||||
return {
|
||||
'certificate_usage': certificate_usage,
|
||||
'selector': selector,
|
||||
'matching_type': matching_type,
|
||||
'certificate_association_data': certificate_association_data,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def validate(cls, data, _type):
|
||||
if not isinstance(data, (list, tuple)):
|
||||
data = (data,)
|
||||
reasons = []
|
||||
for value in data:
|
||||
try:
|
||||
certificate_usage = int(value.get('certificate_usage', 0))
|
||||
if certificate_usage < 0 or certificate_usage > 3:
|
||||
reasons.append(
|
||||
f'invalid certificate_usage ' f'"{certificate_usage}"'
|
||||
)
|
||||
except ValueError:
|
||||
reasons.append(
|
||||
f'invalid certificate_usage '
|
||||
f'"{value["certificate_usage"]}"'
|
||||
)
|
||||
|
||||
try:
|
||||
selector = int(value.get('selector', 0))
|
||||
if selector < 0 or selector > 1:
|
||||
reasons.append(f'invalid selector "{selector}"')
|
||||
except ValueError:
|
||||
reasons.append(f'invalid selector "{value["selector"]}"')
|
||||
|
||||
try:
|
||||
matching_type = int(value.get('matching_type', 0))
|
||||
if matching_type < 0 or matching_type > 2:
|
||||
reasons.append(f'invalid matching_type "{matching_type}"')
|
||||
except ValueError:
|
||||
reasons.append(
|
||||
f'invalid matching_type ' f'"{value["matching_type"]}"'
|
||||
)
|
||||
|
||||
if 'certificate_usage' not in value:
|
||||
reasons.append('missing certificate_usage')
|
||||
if 'selector' not in value:
|
||||
reasons.append('missing selector')
|
||||
if 'matching_type' not in value:
|
||||
reasons.append('missing matching_type')
|
||||
if 'certificate_association_data' not in value:
|
||||
reasons.append('missing certificate_association_data')
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def process(cls, values):
|
||||
return [cls(v) for v in values]
|
||||
|
||||
def __init__(self, value):
|
||||
super().__init__(
|
||||
{
|
||||
'certificate_usage': int(value.get('certificate_usage', 0)),
|
||||
'selector': int(value.get('selector', 0)),
|
||||
'matching_type': int(value.get('matching_type', 0)),
|
||||
# force it to a string, in case the hex has only numerical
|
||||
# values and it was converted to an int at some point
|
||||
# TODO: this needed on any others?
|
||||
'certificate_association_data': str(
|
||||
value['certificate_association_data']
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def certificate_usage(self):
|
||||
return self['certificate_usage']
|
||||
|
||||
@certificate_usage.setter
|
||||
def certificate_usage(self, value):
|
||||
self['certificate_usage'] = value
|
||||
|
||||
@property
|
||||
def selector(self):
|
||||
return self['selector']
|
||||
|
||||
@selector.setter
|
||||
def selector(self, value):
|
||||
self['selector'] = value
|
||||
|
||||
@property
|
||||
def matching_type(self):
|
||||
return self['matching_type']
|
||||
|
||||
@matching_type.setter
|
||||
def matching_type(self, value):
|
||||
self['matching_type'] = value
|
||||
|
||||
@property
|
||||
def certificate_association_data(self):
|
||||
return self['certificate_association_data']
|
||||
|
||||
@certificate_association_data.setter
|
||||
def certificate_association_data(self, value):
|
||||
self['certificate_association_data'] = value
|
||||
|
||||
@property
|
||||
def rdata_text(self):
|
||||
return f'{self.certificate_usage} {self.selector} {self.matching_type} {self.certificate_association_data}'
|
||||
|
||||
def _equality_tuple(self):
|
||||
return (
|
||||
self.certificate_usage,
|
||||
self.selector,
|
||||
self.matching_type,
|
||||
self.certificate_association_data,
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"'{self.certificate_usage} {self.selector} '"
|
||||
f"'{self.matching_type} {self.certificate_association_data}'"
|
||||
)
|
||||
|
||||
|
||||
class TlsaRecord(ValuesMixin, Record):
|
||||
_type = 'TLSA'
|
||||
_value_type = TlsaValue
|
||||
|
||||
|
||||
Record.register_type(TlsaRecord)
|
||||
@@ -0,0 +1,18 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from .base import Record
|
||||
from .chunked import _ChunkedValue, _ChunkedValuesMixin
|
||||
|
||||
|
||||
class TxtValue(_ChunkedValue):
|
||||
pass
|
||||
|
||||
|
||||
class TxtRecord(_ChunkedValuesMixin, Record):
|
||||
_type = 'TXT'
|
||||
_value_type = TxtValue
|
||||
|
||||
|
||||
Record.register_type(TxtRecord)
|
||||
@@ -0,0 +1,121 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from ..equality import EqualityTupleMixin
|
||||
from .base import Record, ValuesMixin
|
||||
|
||||
|
||||
class UrlfwdValue(EqualityTupleMixin, dict):
|
||||
VALID_CODES = (301, 302)
|
||||
VALID_MASKS = (0, 1, 2)
|
||||
VALID_QUERY = (0, 1)
|
||||
|
||||
@classmethod
|
||||
def validate(cls, data, _type):
|
||||
if not isinstance(data, (list, tuple)):
|
||||
data = (data,)
|
||||
reasons = []
|
||||
for value in data:
|
||||
try:
|
||||
code = int(value['code'])
|
||||
if code not in cls.VALID_CODES:
|
||||
reasons.append(f'unrecognized return code "{code}"')
|
||||
except KeyError:
|
||||
reasons.append('missing code')
|
||||
except ValueError:
|
||||
reasons.append(f'invalid return code "{value["code"]}"')
|
||||
try:
|
||||
masking = int(value['masking'])
|
||||
if masking not in cls.VALID_MASKS:
|
||||
reasons.append(f'unrecognized masking setting "{masking}"')
|
||||
except KeyError:
|
||||
reasons.append('missing masking')
|
||||
except ValueError:
|
||||
reasons.append(f'invalid masking setting "{value["masking"]}"')
|
||||
try:
|
||||
query = int(value['query'])
|
||||
if query not in cls.VALID_QUERY:
|
||||
reasons.append(f'unrecognized query setting "{query}"')
|
||||
except KeyError:
|
||||
reasons.append('missing query')
|
||||
except ValueError:
|
||||
reasons.append(f'invalid query setting "{value["query"]}"')
|
||||
for k in ('path', 'target'):
|
||||
if k not in value:
|
||||
reasons.append(f'missing {k}')
|
||||
return reasons
|
||||
|
||||
@classmethod
|
||||
def process(cls, values):
|
||||
return [cls(v) for v in values]
|
||||
|
||||
def __init__(self, value):
|
||||
super().__init__(
|
||||
{
|
||||
'path': value['path'],
|
||||
'target': value['target'],
|
||||
'code': int(value['code']),
|
||||
'masking': int(value['masking']),
|
||||
'query': int(value['query']),
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
return self['path']
|
||||
|
||||
@path.setter
|
||||
def path(self, value):
|
||||
self['path'] = value
|
||||
|
||||
@property
|
||||
def target(self):
|
||||
return self['target']
|
||||
|
||||
@target.setter
|
||||
def target(self, value):
|
||||
self['target'] = value
|
||||
|
||||
@property
|
||||
def code(self):
|
||||
return self['code']
|
||||
|
||||
@code.setter
|
||||
def code(self, value):
|
||||
self['code'] = value
|
||||
|
||||
@property
|
||||
def masking(self):
|
||||
return self['masking']
|
||||
|
||||
@masking.setter
|
||||
def masking(self, value):
|
||||
self['masking'] = value
|
||||
|
||||
@property
|
||||
def query(self):
|
||||
return self['query']
|
||||
|
||||
@query.setter
|
||||
def query(self, value):
|
||||
self['query'] = value
|
||||
|
||||
def _equality_tuple(self):
|
||||
return (self.path, self.target, self.code, self.masking, self.query)
|
||||
|
||||
def __hash__(self):
|
||||
return hash(
|
||||
(self.path, self.target, self.code, self.masking, self.query)
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'"{self.path}" "{self.target}" {self.code} {self.masking} {self.query}'
|
||||
|
||||
|
||||
class UrlfwdRecord(ValuesMixin, Record):
|
||||
_type = 'URLFWD'
|
||||
_value_type = UrlfwdValue
|
||||
|
||||
|
||||
Record.register_type(UrlfwdRecord)
|
||||
@@ -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)
|
||||
|
||||
+3
-5876
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,181 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.record.a import ARecord
|
||||
from octodns.record.exception import ValidationError
|
||||
from octodns.zone import Zone
|
||||
|
||||
from helpers import SimpleProvider
|
||||
|
||||
|
||||
class TestRecordA(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
|
||||
def test_a_and_record(self):
|
||||
a_values = ['1.2.3.4', '2.2.3.4']
|
||||
a_data = {'ttl': 30, 'values': a_values}
|
||||
a = ARecord(self.zone, 'a', a_data)
|
||||
self.assertEqual('a', a.name)
|
||||
self.assertEqual('a.unit.tests.', a.fqdn)
|
||||
self.assertEqual(30, a.ttl)
|
||||
self.assertEqual(a_values, a.values)
|
||||
self.assertEqual(a_data, a.data)
|
||||
|
||||
b_value = '3.2.3.4'
|
||||
b_data = {'ttl': 30, 'value': b_value}
|
||||
b = ARecord(self.zone, 'b', b_data)
|
||||
self.assertEqual([b_value], b.values)
|
||||
self.assertEqual(b_data, b.data)
|
||||
|
||||
# top-level
|
||||
data = {'ttl': 30, 'value': '4.2.3.4'}
|
||||
self.assertEqual(self.zone.name, ARecord(self.zone, '', data).fqdn)
|
||||
self.assertEqual(self.zone.name, ARecord(self.zone, None, data).fqdn)
|
||||
|
||||
# ARecord equate with itself
|
||||
self.assertTrue(a == a)
|
||||
# Records with differing names and same type don't equate
|
||||
self.assertFalse(a == b)
|
||||
# Records with same name & type equate even if ttl is different
|
||||
self.assertTrue(
|
||||
a == ARecord(self.zone, 'a', {'ttl': 31, 'values': a_values})
|
||||
)
|
||||
# Records with same name & type equate even if values are different
|
||||
self.assertTrue(
|
||||
a == ARecord(self.zone, 'a', {'ttl': 30, 'value': b_value})
|
||||
)
|
||||
|
||||
target = SimpleProvider()
|
||||
# no changes if self
|
||||
self.assertFalse(a.changes(a, target))
|
||||
# no changes if clone
|
||||
other = ARecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
|
||||
self.assertFalse(a.changes(other, target))
|
||||
# changes if ttl modified
|
||||
other.ttl = 31
|
||||
update = a.changes(other, target)
|
||||
self.assertEqual(a, update.existing)
|
||||
self.assertEqual(other, update.new)
|
||||
# changes if values modified
|
||||
other.ttl = a.ttl
|
||||
other.values = ['4.4.4.4']
|
||||
update = a.changes(other, target)
|
||||
self.assertEqual(a, update.existing)
|
||||
self.assertEqual(other, update.new)
|
||||
|
||||
# Hashing
|
||||
records = set()
|
||||
records.add(a)
|
||||
self.assertTrue(a in records)
|
||||
self.assertFalse(b in records)
|
||||
records.add(b)
|
||||
self.assertTrue(b in records)
|
||||
|
||||
# __repr__ doesn't blow up
|
||||
a.__repr__()
|
||||
# Record.__repr__ does
|
||||
with self.assertRaises(NotImplementedError):
|
||||
|
||||
class DummyRecord(Record):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
DummyRecord().__repr__()
|
||||
|
||||
def test_validation_and_values_mixin(self):
|
||||
# doesn't blow up
|
||||
Record.new(self.zone, '', {'type': 'A', 'ttl': 600, 'value': '1.2.3.4'})
|
||||
Record.new(
|
||||
self.zone, '', {'type': 'A', 'ttl': 600, 'values': ['1.2.3.4']}
|
||||
)
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{'type': 'A', 'ttl': 600, 'values': ['1.2.3.4', '1.2.3.5']},
|
||||
)
|
||||
|
||||
# missing value(s), no value or value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {'type': 'A', 'ttl': 600})
|
||||
self.assertEqual(['missing value(s)'], ctx.exception.reasons)
|
||||
|
||||
# missing value(s), empty values
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, 'www', {'type': 'A', 'ttl': 600, 'values': []}
|
||||
)
|
||||
self.assertEqual(['missing value(s)'], ctx.exception.reasons)
|
||||
|
||||
# missing value(s), None values
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, 'www', {'type': 'A', 'ttl': 600, 'values': None}
|
||||
)
|
||||
self.assertEqual(['missing value(s)'], ctx.exception.reasons)
|
||||
|
||||
# missing value(s) and empty value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'www',
|
||||
{'type': 'A', 'ttl': 600, 'values': [None, '']},
|
||||
)
|
||||
self.assertEqual(
|
||||
['missing value(s)', 'empty value'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# missing value(s), None value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, 'www', {'type': 'A', 'ttl': 600, 'value': None}
|
||||
)
|
||||
self.assertEqual(['missing value(s)'], ctx.exception.reasons)
|
||||
|
||||
# empty value, empty string value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, 'www', {'type': 'A', 'ttl': 600, 'value': ''})
|
||||
self.assertEqual(['empty value'], ctx.exception.reasons)
|
||||
|
||||
# missing value(s) & ttl
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {'type': 'A'})
|
||||
self.assertEqual(
|
||||
['missing ttl', 'missing value(s)'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# invalid ipv4 address
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, '', {'type': 'A', 'ttl': 600, 'value': 'hello'}
|
||||
)
|
||||
self.assertEqual(
|
||||
['invalid IPv4 address "hello"'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# invalid ipv4 addresses
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{'type': 'A', 'ttl': 600, 'values': ['hello', 'goodbye']},
|
||||
)
|
||||
self.assertEqual(
|
||||
['invalid IPv4 address "hello"', 'invalid IPv4 address "goodbye"'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
|
||||
# invalid & valid ipv4 addresses, no ttl
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{'type': 'A', 'values': ['1.2.3.4', 'hello', '5.6.7.8']},
|
||||
)
|
||||
self.assertEqual(
|
||||
['missing ttl', 'invalid IPv4 address "hello"'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
@@ -0,0 +1,227 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.record.aaaa import AaaaRecord
|
||||
from octodns.record.exception import ValidationError
|
||||
from octodns.zone import Zone
|
||||
|
||||
|
||||
class TestRecordAaaa(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
|
||||
def assertMultipleValues(self, _type, a_values, b_value):
|
||||
a_data = {'ttl': 30, 'values': a_values}
|
||||
a = _type(self.zone, 'a', a_data)
|
||||
self.assertEqual('a', a.name)
|
||||
self.assertEqual('a.unit.tests.', a.fqdn)
|
||||
self.assertEqual(30, a.ttl)
|
||||
self.assertEqual(a_values, a.values)
|
||||
self.assertEqual(a_data, a.data)
|
||||
|
||||
b_data = {'ttl': 30, 'value': b_value}
|
||||
b = _type(self.zone, 'b', b_data)
|
||||
self.assertEqual([b_value], b.values)
|
||||
self.assertEqual(b_data, b.data)
|
||||
|
||||
def test_aaaa(self):
|
||||
a_values = [
|
||||
'2001:db8:3c4d:15::1a2f:1a2b',
|
||||
'2001:db8:3c4d:15::1a2f:1a3b',
|
||||
]
|
||||
b_value = '2001:db8:3c4d:15::1a2f:1a4b'
|
||||
self.assertMultipleValues(AaaaRecord, a_values, b_value)
|
||||
|
||||
# Specifically validate that we normalize IPv6 addresses
|
||||
values = [
|
||||
'2001:db8:3c4d:15:0000:0000:1a2f:1a2b',
|
||||
'2001:0db8:3c4d:0015::1a2f:1a3b',
|
||||
]
|
||||
data = {'ttl': 30, 'values': values}
|
||||
record = AaaaRecord(self.zone, 'aaaa', data)
|
||||
self.assertEqual(a_values, record.values)
|
||||
|
||||
def test_validation(self):
|
||||
# doesn't blow up
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'AAAA',
|
||||
'ttl': 600,
|
||||
'value': '2601:644:500:e210:62f8:1dff:feb8:947a',
|
||||
},
|
||||
)
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'AAAA',
|
||||
'ttl': 600,
|
||||
'values': ['2601:644:500:e210:62f8:1dff:feb8:947a'],
|
||||
},
|
||||
)
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'AAAA',
|
||||
'ttl': 600,
|
||||
'values': [
|
||||
'2601:644:500:e210:62f8:1dff:feb8:947a',
|
||||
'2601:642:500:e210:62f8:1dff:feb8:947a',
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
# missing value(s), no value or value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {'type': 'AAAA', 'ttl': 600})
|
||||
self.assertEqual(['missing value(s)'], ctx.exception.reasons)
|
||||
|
||||
# missing value(s), empty values
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, 'www', {'type': 'AAAA', 'ttl': 600, 'values': []}
|
||||
)
|
||||
self.assertEqual(['missing value(s)'], ctx.exception.reasons)
|
||||
|
||||
# missing value(s), None values
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, 'www', {'type': 'AAAA', 'ttl': 600, 'values': None}
|
||||
)
|
||||
self.assertEqual(['missing value(s)'], ctx.exception.reasons)
|
||||
|
||||
# missing value(s) and empty value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'www',
|
||||
{'type': 'AAAA', 'ttl': 600, 'values': [None, '']},
|
||||
)
|
||||
self.assertEqual(
|
||||
['missing value(s)', 'empty value'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# missing value(s), None value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, 'www', {'type': 'AAAA', 'ttl': 600, 'value': None}
|
||||
)
|
||||
self.assertEqual(['missing value(s)'], ctx.exception.reasons)
|
||||
|
||||
# empty value, empty string value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, 'www', {'type': 'AAAA', 'ttl': 600, 'value': ''}
|
||||
)
|
||||
self.assertEqual(['empty value'], ctx.exception.reasons)
|
||||
|
||||
# missing value(s) & ttl
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {'type': 'AAAA'})
|
||||
self.assertEqual(
|
||||
['missing ttl', 'missing value(s)'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# invalid IPv6 address
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, '', {'type': 'AAAA', 'ttl': 600, 'value': 'hello'}
|
||||
)
|
||||
self.assertEqual(
|
||||
['invalid IPv6 address "hello"'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# invalid IPv6 addresses
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{'type': 'AAAA', 'ttl': 600, 'values': ['hello', 'goodbye']},
|
||||
)
|
||||
self.assertEqual(
|
||||
['invalid IPv6 address "hello"', 'invalid IPv6 address "goodbye"'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
|
||||
# invalid & valid IPv6 addresses, no ttl
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'AAAA',
|
||||
'values': [
|
||||
'2601:644:500:e210:62f8:1dff:feb8:947a',
|
||||
'hello',
|
||||
'2601:642:500:e210:62f8:1dff:feb8:947a',
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['missing ttl', 'invalid IPv6 address "hello"'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
|
||||
def test_more_validation(self):
|
||||
# doesn't blow up
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'AAAA',
|
||||
'ttl': 600,
|
||||
'value': '2601:644:500:e210:62f8:1dff:feb8:947a',
|
||||
},
|
||||
)
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'AAAA',
|
||||
'ttl': 600,
|
||||
'values': [
|
||||
'2601:644:500:e210:62f8:1dff:feb8:947a',
|
||||
'2601:644:500:e210:62f8:1dff:feb8:947b',
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
# invalid ip address
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, '', {'type': 'AAAA', 'ttl': 600, 'value': 'hello'}
|
||||
)
|
||||
self.assertEqual(
|
||||
['invalid IPv6 address "hello"'], ctx.exception.reasons
|
||||
)
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{'type': 'AAAA', 'ttl': 600, 'values': ['1.2.3.4', '2.3.4.5']},
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
'invalid IPv6 address "1.2.3.4"',
|
||||
'invalid IPv6 address "2.3.4.5"',
|
||||
],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
|
||||
# invalid ip addresses
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{'type': 'AAAA', 'ttl': 600, 'values': ['hello', 'goodbye']},
|
||||
)
|
||||
self.assertEqual(
|
||||
['invalid IPv6 address "hello"', 'invalid IPv6 address "goodbye"'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
@@ -0,0 +1,108 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.record.alias import AliasRecord
|
||||
from octodns.record.exception import ValidationError
|
||||
from octodns.zone import Zone
|
||||
|
||||
from helpers import SimpleProvider
|
||||
|
||||
|
||||
class TestRecordAlias(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
|
||||
def test_alias(self):
|
||||
a_data = {'ttl': 0, 'value': 'www.unit.tests.'}
|
||||
a = AliasRecord(self.zone, '', a_data)
|
||||
self.assertEqual('', a.name)
|
||||
self.assertEqual('unit.tests.', a.fqdn)
|
||||
self.assertEqual(0, a.ttl)
|
||||
self.assertEqual(a_data['value'], a.value)
|
||||
self.assertEqual(a_data, a.data)
|
||||
|
||||
target = SimpleProvider()
|
||||
# No changes with self
|
||||
self.assertFalse(a.changes(a, target))
|
||||
# Diff in value causes change
|
||||
other = AliasRecord(self.zone, 'a', a_data)
|
||||
other.value = 'foo.unit.tests.'
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
|
||||
# __repr__ doesn't blow up
|
||||
a.__repr__()
|
||||
|
||||
def test_alias_lowering_value(self):
|
||||
upper_record = AliasRecord(
|
||||
self.zone,
|
||||
'aliasUppwerValue',
|
||||
{'ttl': 30, 'type': 'ALIAS', 'value': 'GITHUB.COM'},
|
||||
)
|
||||
lower_record = AliasRecord(
|
||||
self.zone,
|
||||
'aliasLowerValue',
|
||||
{'ttl': 30, 'type': 'ALIAS', 'value': 'github.com'},
|
||||
)
|
||||
self.assertEqual(upper_record.value, lower_record.value)
|
||||
|
||||
def test_validation_and_value_mixin(self):
|
||||
# doesn't blow up
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{'type': 'ALIAS', 'ttl': 600, 'value': 'foo.bar.com.'},
|
||||
)
|
||||
|
||||
# root only
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'nope',
|
||||
{'type': 'ALIAS', 'ttl': 600, 'value': 'foo.bar.com.'},
|
||||
)
|
||||
self.assertEqual(['non-root ALIAS not allowed'], ctx.exception.reasons)
|
||||
|
||||
# missing value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {'type': 'ALIAS', 'ttl': 600})
|
||||
self.assertEqual(['missing value'], ctx.exception.reasons)
|
||||
|
||||
# missing value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, '', {'type': 'ALIAS', 'ttl': 600, 'value': None}
|
||||
)
|
||||
self.assertEqual(['missing value'], ctx.exception.reasons)
|
||||
|
||||
# empty value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, '', {'type': 'ALIAS', 'ttl': 600, 'value': ''}
|
||||
)
|
||||
self.assertEqual(['empty value'], ctx.exception.reasons)
|
||||
|
||||
# not a valid FQDN
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, '', {'type': 'ALIAS', 'ttl': 600, 'value': '__.'}
|
||||
)
|
||||
self.assertEqual(
|
||||
['ALIAS value "__." is not a valid FQDN'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# missing trailing .
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{'type': 'ALIAS', 'ttl': 600, 'value': 'foo.bar.com'},
|
||||
)
|
||||
self.assertEqual(
|
||||
['ALIAS value "foo.bar.com" missing trailing .'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
@@ -0,0 +1,273 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.record.caa import CaaRecord, CaaValue
|
||||
from octodns.record.exception import ValidationError
|
||||
from octodns.record.rr import RrParseError
|
||||
from octodns.zone import Zone
|
||||
|
||||
from helpers import SimpleProvider
|
||||
|
||||
|
||||
class TestRecordCaa(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
|
||||
def test_caa(self):
|
||||
a_values = [
|
||||
CaaValue({'flags': 0, 'tag': 'issue', 'value': 'ca.example.net'}),
|
||||
CaaValue(
|
||||
{
|
||||
'flags': 128,
|
||||
'tag': 'iodef',
|
||||
'value': 'mailto:[email protected]',
|
||||
}
|
||||
),
|
||||
]
|
||||
a_data = {'ttl': 30, 'values': a_values}
|
||||
a = CaaRecord(self.zone, 'a', a_data)
|
||||
self.assertEqual('a', a.name)
|
||||
self.assertEqual('a.unit.tests.', a.fqdn)
|
||||
self.assertEqual(30, a.ttl)
|
||||
self.assertEqual(a_values[0]['flags'], a.values[0].flags)
|
||||
self.assertEqual(a_values[0]['tag'], a.values[0].tag)
|
||||
self.assertEqual(a_values[0]['value'], a.values[0].value)
|
||||
self.assertEqual(a_values[1]['flags'], a.values[1].flags)
|
||||
self.assertEqual(a_values[1]['tag'], a.values[1].tag)
|
||||
self.assertEqual(a_values[1]['value'], a.values[1].value)
|
||||
self.assertEqual(a_data, a.data)
|
||||
|
||||
b_value = CaaValue(
|
||||
{'tag': 'iodef', 'value': 'http://iodef.example.com/'}
|
||||
)
|
||||
b_data = {'ttl': 30, 'value': b_value}
|
||||
b = CaaRecord(self.zone, 'b', b_data)
|
||||
self.assertEqual(0, b.values[0].flags)
|
||||
self.assertEqual(b_value['tag'], b.values[0].tag)
|
||||
self.assertEqual(b_value['value'], b.values[0].value)
|
||||
b_data['value']['flags'] = 0
|
||||
self.assertEqual(b_data, b.data)
|
||||
|
||||
target = SimpleProvider()
|
||||
# No changes with self
|
||||
self.assertFalse(a.changes(a, target))
|
||||
# Diff in flags causes change
|
||||
other = CaaRecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
|
||||
other.values[0].flags = 128
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
# Diff in tag causes change
|
||||
other.values[0].flags = a.values[0].flags
|
||||
other.values[0].tag = 'foo'
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
# Diff in value causes change
|
||||
other.values[0].tag = a.values[0].tag
|
||||
other.values[0].value = 'bar'
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
|
||||
# __repr__ doesn't blow up
|
||||
a.__repr__()
|
||||
|
||||
def test_caa_value_rdata_text(self):
|
||||
# empty string won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
CaaValue.parse_rdata_text('')
|
||||
|
||||
# single word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
CaaValue.parse_rdata_text('nope')
|
||||
|
||||
# 2nd word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
CaaValue.parse_rdata_text('0 tag')
|
||||
|
||||
# 4th word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
CaaValue.parse_rdata_text('1 tag value another')
|
||||
|
||||
# flags not an int, will parse
|
||||
self.assertEqual(
|
||||
{'flags': 'one', 'tag': 'tag', 'value': 'value'},
|
||||
CaaValue.parse_rdata_text('one tag value'),
|
||||
)
|
||||
|
||||
# valid
|
||||
self.assertEqual(
|
||||
{'flags': 0, 'tag': 'tag', 'value': '99148c81'},
|
||||
CaaValue.parse_rdata_text('0 tag 99148c81'),
|
||||
)
|
||||
|
||||
zone = Zone('unit.tests.', [])
|
||||
a = CaaRecord(
|
||||
zone,
|
||||
'caa',
|
||||
{
|
||||
'ttl': 32,
|
||||
'values': [
|
||||
{'flags': 1, 'tag': 'tag1', 'value': '99148c81'},
|
||||
{'flags': 2, 'tag': 'tag2', 'value': '99148c44'},
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertEqual(1, a.values[0].flags)
|
||||
self.assertEqual('tag1', a.values[0].tag)
|
||||
self.assertEqual('99148c81', a.values[0].value)
|
||||
self.assertEqual('1 tag1 99148c81', a.values[0].rdata_text)
|
||||
self.assertEqual(2, a.values[1].flags)
|
||||
self.assertEqual('tag2', a.values[1].tag)
|
||||
self.assertEqual('99148c44', a.values[1].value)
|
||||
self.assertEqual('2 tag2 99148c44', a.values[1].rdata_text)
|
||||
|
||||
def test_caa_value(self):
|
||||
a = CaaValue({'flags': 0, 'tag': 'a', 'value': 'v'})
|
||||
b = CaaValue({'flags': 1, 'tag': 'a', 'value': 'v'})
|
||||
c = CaaValue({'flags': 0, 'tag': 'c', 'value': 'v'})
|
||||
d = CaaValue({'flags': 0, 'tag': 'a', 'value': 'z'})
|
||||
|
||||
self.assertEqual(a, a)
|
||||
self.assertEqual(b, b)
|
||||
self.assertEqual(c, c)
|
||||
self.assertEqual(d, d)
|
||||
|
||||
self.assertNotEqual(a, b)
|
||||
self.assertNotEqual(a, c)
|
||||
self.assertNotEqual(a, d)
|
||||
self.assertNotEqual(b, a)
|
||||
self.assertNotEqual(b, c)
|
||||
self.assertNotEqual(b, d)
|
||||
self.assertNotEqual(c, a)
|
||||
self.assertNotEqual(c, b)
|
||||
self.assertNotEqual(c, d)
|
||||
|
||||
self.assertTrue(a < b)
|
||||
self.assertTrue(a < c)
|
||||
self.assertTrue(a < d)
|
||||
|
||||
self.assertTrue(b > a)
|
||||
self.assertTrue(b > c)
|
||||
self.assertTrue(b > d)
|
||||
|
||||
self.assertTrue(c > a)
|
||||
self.assertTrue(c < b)
|
||||
self.assertTrue(c > d)
|
||||
|
||||
self.assertTrue(d > a)
|
||||
self.assertTrue(d < b)
|
||||
self.assertTrue(d < c)
|
||||
|
||||
self.assertTrue(a <= b)
|
||||
self.assertTrue(a <= c)
|
||||
self.assertTrue(a <= d)
|
||||
self.assertTrue(a <= a)
|
||||
self.assertTrue(a >= a)
|
||||
|
||||
self.assertTrue(b >= a)
|
||||
self.assertTrue(b >= c)
|
||||
self.assertTrue(b >= d)
|
||||
self.assertTrue(b >= b)
|
||||
self.assertTrue(b <= b)
|
||||
|
||||
self.assertTrue(c >= a)
|
||||
self.assertTrue(c <= b)
|
||||
self.assertTrue(c >= d)
|
||||
self.assertTrue(c >= c)
|
||||
self.assertTrue(c <= c)
|
||||
|
||||
self.assertTrue(d >= a)
|
||||
self.assertTrue(d <= b)
|
||||
self.assertTrue(d <= c)
|
||||
self.assertTrue(d >= d)
|
||||
self.assertTrue(d <= d)
|
||||
|
||||
def test_validation(self):
|
||||
# doesn't blow up
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'CAA',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'flags': 128,
|
||||
'tag': 'iodef',
|
||||
'value': 'http://foo.bar.com/',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# invalid flags
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'CAA',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'flags': -42,
|
||||
'tag': 'iodef',
|
||||
'value': 'http://foo.bar.com/',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['invalid flags "-42"'], ctx.exception.reasons)
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'CAA',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'flags': 442,
|
||||
'tag': 'iodef',
|
||||
'value': 'http://foo.bar.com/',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['invalid flags "442"'], ctx.exception.reasons)
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'CAA',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'flags': 'nope',
|
||||
'tag': 'iodef',
|
||||
'value': 'http://foo.bar.com/',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['invalid flags "nope"'], ctx.exception.reasons)
|
||||
|
||||
# missing tag
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'CAA',
|
||||
'ttl': 600,
|
||||
'value': {'value': 'http://foo.bar.com/'},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing tag'], ctx.exception.reasons)
|
||||
|
||||
# missing value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{'type': 'CAA', 'ttl': 600, 'value': {'tag': 'iodef'}},
|
||||
)
|
||||
self.assertEqual(['missing value'], ctx.exception.reasons)
|
||||
@@ -0,0 +1,92 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.record.change import Create, Delete, Update
|
||||
from octodns.zone import Zone
|
||||
|
||||
|
||||
class TestChanges(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
record_a_1 = Record.new(
|
||||
zone, '1', {'type': 'A', 'ttl': 30, 'value': '1.2.3.4'}
|
||||
)
|
||||
record_a_2 = Record.new(
|
||||
zone, '2', {'type': 'A', 'ttl': 30, 'value': '1.2.3.4'}
|
||||
)
|
||||
record_aaaa_1 = Record.new(
|
||||
zone,
|
||||
'1',
|
||||
{
|
||||
'type': 'AAAA',
|
||||
'ttl': 30,
|
||||
'value': '2601:644:500:e210:62f8:1dff:feb8:947a',
|
||||
},
|
||||
)
|
||||
record_aaaa_2 = Record.new(
|
||||
zone,
|
||||
'2',
|
||||
{
|
||||
'type': 'AAAA',
|
||||
'ttl': 30,
|
||||
'value': '2601:644:500:e210:62f8:1dff:feb8:947a',
|
||||
},
|
||||
)
|
||||
|
||||
def test_sort_same_change_type(self):
|
||||
# expect things to be ordered by name and type since all the change
|
||||
# types are the same it doesn't matter
|
||||
changes = [
|
||||
Create(self.record_aaaa_1),
|
||||
Create(self.record_a_2),
|
||||
Create(self.record_a_1),
|
||||
Create(self.record_aaaa_2),
|
||||
]
|
||||
self.assertEqual(
|
||||
[
|
||||
Create(self.record_a_1),
|
||||
Create(self.record_aaaa_1),
|
||||
Create(self.record_a_2),
|
||||
Create(self.record_aaaa_2),
|
||||
],
|
||||
sorted(changes),
|
||||
)
|
||||
|
||||
def test_sort_same_different_type(self):
|
||||
# this time the change type is the deciding factor, deletes come before
|
||||
# creates, and then updates. Things of the same type, go down the line
|
||||
# and sort by name, and then type
|
||||
changes = [
|
||||
Delete(self.record_aaaa_1),
|
||||
Create(self.record_aaaa_1),
|
||||
Update(self.record_aaaa_1, self.record_aaaa_1),
|
||||
Update(self.record_a_1, self.record_a_1),
|
||||
Create(self.record_a_1),
|
||||
Delete(self.record_a_1),
|
||||
Delete(self.record_aaaa_2),
|
||||
Create(self.record_aaaa_2),
|
||||
Update(self.record_aaaa_2, self.record_aaaa_2),
|
||||
Update(self.record_a_2, self.record_a_2),
|
||||
Create(self.record_a_2),
|
||||
Delete(self.record_a_2),
|
||||
]
|
||||
self.assertEqual(
|
||||
[
|
||||
Delete(self.record_a_1),
|
||||
Delete(self.record_aaaa_1),
|
||||
Delete(self.record_a_2),
|
||||
Delete(self.record_aaaa_2),
|
||||
Create(self.record_a_1),
|
||||
Create(self.record_aaaa_1),
|
||||
Create(self.record_a_2),
|
||||
Create(self.record_aaaa_2),
|
||||
Update(self.record_a_1, self.record_a_1),
|
||||
Update(self.record_aaaa_1, self.record_aaaa_1),
|
||||
Update(self.record_a_2, self.record_a_2),
|
||||
Update(self.record_aaaa_2, self.record_aaaa_2),
|
||||
],
|
||||
sorted(changes),
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record.chunked import _ChunkedValue
|
||||
from octodns.record.spf import SpfRecord
|
||||
from octodns.zone import Zone
|
||||
|
||||
|
||||
class TestRecordChunked(TestCase):
|
||||
def test_chunked_value_rdata_text(self):
|
||||
for s in (
|
||||
None,
|
||||
'',
|
||||
'word',
|
||||
42,
|
||||
42.43,
|
||||
'1.2.3',
|
||||
'some.words.that.here',
|
||||
'1.2.word.4',
|
||||
'1.2.3.4',
|
||||
):
|
||||
self.assertEqual(s, _ChunkedValue.parse_rdata_text(s))
|
||||
|
||||
# semi-colons are escaped
|
||||
self.assertEqual(
|
||||
'Hello\\; World!', _ChunkedValue.parse_rdata_text('Hello; World!')
|
||||
)
|
||||
|
||||
# since we're always a string validate and __init__ don't
|
||||
# parse_rdata_text
|
||||
|
||||
zone = Zone('unit.tests.', [])
|
||||
a = SpfRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'})
|
||||
self.assertEqual('some.target.', a.values[0].rdata_text)
|
||||
@@ -0,0 +1,136 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.record.cname import CnameRecord
|
||||
from octodns.record.exception import ValidationError
|
||||
from octodns.zone import Zone
|
||||
|
||||
from helpers import SimpleProvider
|
||||
|
||||
|
||||
class TestRecordCname(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
|
||||
def assertSingleValue(self, _type, a_value, b_value):
|
||||
a_data = {'ttl': 30, 'value': a_value}
|
||||
a = _type(self.zone, 'a', a_data)
|
||||
self.assertEqual('a', a.name)
|
||||
self.assertEqual('a.unit.tests.', a.fqdn)
|
||||
self.assertEqual(30, a.ttl)
|
||||
self.assertEqual(a_value, a.value)
|
||||
self.assertEqual(a_data, a.data)
|
||||
|
||||
b_data = {'ttl': 30, 'value': b_value}
|
||||
b = _type(self.zone, 'b', b_data)
|
||||
self.assertEqual(b_value, b.value)
|
||||
self.assertEqual(b_data, b.data)
|
||||
|
||||
target = SimpleProvider()
|
||||
# No changes with self
|
||||
self.assertFalse(a.changes(a, target))
|
||||
# Diff in value causes change
|
||||
other = _type(self.zone, 'a', {'ttl': 30, 'value': b_value})
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
|
||||
# __repr__ doesn't blow up
|
||||
a.__repr__()
|
||||
|
||||
def test_cname(self):
|
||||
self.assertSingleValue(CnameRecord, 'target.foo.com.', 'other.foo.com.')
|
||||
|
||||
def test_cname_lowering_value(self):
|
||||
upper_record = CnameRecord(
|
||||
self.zone,
|
||||
'CnameUppwerValue',
|
||||
{'ttl': 30, 'type': 'CNAME', 'value': 'GITHUB.COM'},
|
||||
)
|
||||
lower_record = CnameRecord(
|
||||
self.zone,
|
||||
'CnameLowerValue',
|
||||
{'ttl': 30, 'type': 'CNAME', 'value': 'github.com'},
|
||||
)
|
||||
self.assertEqual(upper_record.value, lower_record.value)
|
||||
|
||||
def test_validation(self):
|
||||
# doesn't blow up
|
||||
Record.new(
|
||||
self.zone,
|
||||
'www',
|
||||
{'type': 'CNAME', 'ttl': 600, 'value': 'foo.bar.com.'},
|
||||
)
|
||||
|
||||
# root cname is a no-no
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{'type': 'CNAME', 'ttl': 600, 'value': 'foo.bar.com.'},
|
||||
)
|
||||
self.assertEqual(['root CNAME not allowed'], ctx.exception.reasons)
|
||||
|
||||
# not a valid FQDN
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, 'www', {'type': 'CNAME', 'ttl': 600, 'value': '___.'}
|
||||
)
|
||||
self.assertEqual(
|
||||
['CNAME value "___." is not a valid FQDN'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# missing trailing .
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'www',
|
||||
{'type': 'CNAME', 'ttl': 600, 'value': 'foo.bar.com'},
|
||||
)
|
||||
self.assertEqual(
|
||||
['CNAME value "foo.bar.com" missing trailing .'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
|
||||
# doesn't allow urls
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'www',
|
||||
{'type': 'CNAME', 'ttl': 600, 'value': 'https://google.com'},
|
||||
)
|
||||
self.assertEqual(
|
||||
['CNAME value "https://google.com" is not a valid FQDN'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
|
||||
# doesn't allow urls with paths
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'www',
|
||||
{
|
||||
'type': 'CNAME',
|
||||
'ttl': 600,
|
||||
'value': 'https://google.com/a/b/c',
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['CNAME value "https://google.com/a/b/c" is not a valid FQDN'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
|
||||
# doesn't allow paths
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'www',
|
||||
{'type': 'CNAME', 'ttl': 600, 'value': 'google.com/some/path'},
|
||||
)
|
||||
self.assertEqual(
|
||||
['CNAME value "google.com/some/path" is not a valid FQDN'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.record.dname import DnameRecord
|
||||
from octodns.record.exception import ValidationError
|
||||
from octodns.zone import Zone
|
||||
|
||||
from helpers import SimpleProvider
|
||||
|
||||
|
||||
class TestRecordDname(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
|
||||
def assertSingleValue(self, _type, a_value, b_value):
|
||||
a_data = {'ttl': 30, 'value': a_value}
|
||||
a = _type(self.zone, 'a', a_data)
|
||||
self.assertEqual('a', a.name)
|
||||
self.assertEqual('a.unit.tests.', a.fqdn)
|
||||
self.assertEqual(30, a.ttl)
|
||||
self.assertEqual(a_value, a.value)
|
||||
self.assertEqual(a_data, a.data)
|
||||
|
||||
b_data = {'ttl': 30, 'value': b_value}
|
||||
b = _type(self.zone, 'b', b_data)
|
||||
self.assertEqual(b_value, b.value)
|
||||
self.assertEqual(b_data, b.data)
|
||||
|
||||
target = SimpleProvider()
|
||||
# No changes with self
|
||||
self.assertFalse(a.changes(a, target))
|
||||
# Diff in value causes change
|
||||
other = _type(self.zone, 'a', {'ttl': 30, 'value': b_value})
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
|
||||
# __repr__ doesn't blow up
|
||||
a.__repr__()
|
||||
|
||||
def test_dname(self):
|
||||
self.assertSingleValue(DnameRecord, 'target.foo.com.', 'other.foo.com.')
|
||||
|
||||
def test_dname_lowering_value(self):
|
||||
upper_record = DnameRecord(
|
||||
self.zone,
|
||||
'DnameUppwerValue',
|
||||
{'ttl': 30, 'type': 'DNAME', 'value': 'GITHUB.COM'},
|
||||
)
|
||||
lower_record = DnameRecord(
|
||||
self.zone,
|
||||
'DnameLowerValue',
|
||||
{'ttl': 30, 'type': 'DNAME', 'value': 'github.com'},
|
||||
)
|
||||
self.assertEqual(upper_record.value, lower_record.value)
|
||||
|
||||
def test_validation(self):
|
||||
# A valid DNAME record.
|
||||
Record.new(
|
||||
self.zone,
|
||||
'sub',
|
||||
{'type': 'DNAME', 'ttl': 600, 'value': 'foo.bar.com.'},
|
||||
)
|
||||
|
||||
# A DNAME record can be present at the zone APEX.
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{'type': 'DNAME', 'ttl': 600, 'value': 'foo.bar.com.'},
|
||||
)
|
||||
|
||||
# not a valid FQDN
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, 'www', {'type': 'DNAME', 'ttl': 600, 'value': '.'}
|
||||
)
|
||||
self.assertEqual(
|
||||
['DNAME value "." is not a valid FQDN'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# missing trailing .
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'www',
|
||||
{'type': 'DNAME', 'ttl': 600, 'value': 'foo.bar.com'},
|
||||
)
|
||||
self.assertEqual(
|
||||
['DNAME value "foo.bar.com" missing trailing .'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
@@ -0,0 +1,206 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record.ds import DsRecord, DsValue
|
||||
from octodns.record.rr import RrParseError
|
||||
from octodns.zone import Zone
|
||||
|
||||
|
||||
class TestRecordDs(TestCase):
|
||||
def test_ds(self):
|
||||
for a, b in (
|
||||
# diff flags
|
||||
(
|
||||
{
|
||||
'flags': 0,
|
||||
'protocol': 1,
|
||||
'algorithm': 2,
|
||||
'public_key': 'abcdef0123456',
|
||||
},
|
||||
{
|
||||
'flags': 1,
|
||||
'protocol': 1,
|
||||
'algorithm': 2,
|
||||
'public_key': 'abcdef0123456',
|
||||
},
|
||||
),
|
||||
# diff protocol
|
||||
(
|
||||
{
|
||||
'flags': 0,
|
||||
'protocol': 1,
|
||||
'algorithm': 2,
|
||||
'public_key': 'abcdef0123456',
|
||||
},
|
||||
{
|
||||
'flags': 0,
|
||||
'protocol': 2,
|
||||
'algorithm': 2,
|
||||
'public_key': 'abcdef0123456',
|
||||
},
|
||||
),
|
||||
# diff algorithm
|
||||
(
|
||||
{
|
||||
'flags': 0,
|
||||
'protocol': 1,
|
||||
'algorithm': 2,
|
||||
'public_key': 'abcdef0123456',
|
||||
},
|
||||
{
|
||||
'flags': 0,
|
||||
'protocol': 1,
|
||||
'algorithm': 3,
|
||||
'public_key': 'abcdef0123456',
|
||||
},
|
||||
),
|
||||
# diff public_key
|
||||
(
|
||||
{
|
||||
'flags': 0,
|
||||
'protocol': 1,
|
||||
'algorithm': 2,
|
||||
'public_key': 'abcdef0123456',
|
||||
},
|
||||
{
|
||||
'flags': 0,
|
||||
'protocol': 1,
|
||||
'algorithm': 2,
|
||||
'public_key': 'bcdef0123456a',
|
||||
},
|
||||
),
|
||||
):
|
||||
a = DsValue(a)
|
||||
self.assertEqual(a, a)
|
||||
b = DsValue(b)
|
||||
self.assertEqual(b, b)
|
||||
self.assertNotEqual(a, b)
|
||||
self.assertNotEqual(b, a)
|
||||
self.assertTrue(a < b)
|
||||
|
||||
# empty string won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
DsValue.parse_rdata_text('')
|
||||
|
||||
# single word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
DsValue.parse_rdata_text('nope')
|
||||
|
||||
# 2nd word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
DsValue.parse_rdata_text('0 1')
|
||||
|
||||
# 3rd word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
DsValue.parse_rdata_text('0 1 2')
|
||||
|
||||
# 5th word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
DsValue.parse_rdata_text('0 1 2 key blah')
|
||||
|
||||
# things ints, will parse
|
||||
self.assertEqual(
|
||||
{
|
||||
'flags': 'one',
|
||||
'protocol': 'two',
|
||||
'algorithm': 'three',
|
||||
'public_key': 'key',
|
||||
},
|
||||
DsValue.parse_rdata_text('one two three key'),
|
||||
)
|
||||
|
||||
# valid
|
||||
data = {
|
||||
'flags': 0,
|
||||
'protocol': 1,
|
||||
'algorithm': 2,
|
||||
'public_key': '99148c81',
|
||||
}
|
||||
self.assertEqual(data, DsValue.parse_rdata_text('0 1 2 99148c81'))
|
||||
self.assertEqual([], DsValue.validate(data, 'DS'))
|
||||
|
||||
# missing flags
|
||||
data = {'protocol': 1, 'algorithm': 2, 'public_key': '99148c81'}
|
||||
self.assertEqual(['missing flags'], DsValue.validate(data, 'DS'))
|
||||
# invalid flags
|
||||
data = {
|
||||
'flags': 'a',
|
||||
'protocol': 1,
|
||||
'algorithm': 2,
|
||||
'public_key': '99148c81',
|
||||
}
|
||||
self.assertEqual(['invalid flags "a"'], DsValue.validate(data, 'DS'))
|
||||
|
||||
# missing protocol
|
||||
data = {'flags': 1, 'algorithm': 2, 'public_key': '99148c81'}
|
||||
self.assertEqual(['missing protocol'], DsValue.validate(data, 'DS'))
|
||||
# invalid protocol
|
||||
data = {
|
||||
'flags': 1,
|
||||
'protocol': 'a',
|
||||
'algorithm': 2,
|
||||
'public_key': '99148c81',
|
||||
}
|
||||
self.assertEqual(['invalid protocol "a"'], DsValue.validate(data, 'DS'))
|
||||
|
||||
# missing algorithm
|
||||
data = {'flags': 1, 'protocol': 2, 'public_key': '99148c81'}
|
||||
self.assertEqual(['missing algorithm'], DsValue.validate(data, 'DS'))
|
||||
# invalid algorithm
|
||||
data = {
|
||||
'flags': 1,
|
||||
'protocol': 2,
|
||||
'algorithm': 'a',
|
||||
'public_key': '99148c81',
|
||||
}
|
||||
self.assertEqual(
|
||||
['invalid algorithm "a"'], DsValue.validate(data, 'DS')
|
||||
)
|
||||
|
||||
# missing algorithm (list)
|
||||
data = {'flags': 1, 'protocol': 2, 'algorithm': 3}
|
||||
self.assertEqual(['missing public_key'], DsValue.validate([data], 'DS'))
|
||||
|
||||
zone = Zone('unit.tests.', [])
|
||||
values = [
|
||||
{
|
||||
'flags': 0,
|
||||
'protocol': 1,
|
||||
'algorithm': 2,
|
||||
'public_key': '99148c81',
|
||||
},
|
||||
{
|
||||
'flags': 1,
|
||||
'protocol': 2,
|
||||
'algorithm': 3,
|
||||
'public_key': '99148c44',
|
||||
},
|
||||
]
|
||||
a = DsRecord(zone, 'ds', {'ttl': 32, 'values': values})
|
||||
self.assertEqual(0, a.values[0].flags)
|
||||
a.values[0].flags += 1
|
||||
self.assertEqual(1, a.values[0].flags)
|
||||
|
||||
self.assertEqual(1, a.values[0].protocol)
|
||||
a.values[0].protocol += 1
|
||||
self.assertEqual(2, a.values[0].protocol)
|
||||
|
||||
self.assertEqual(2, a.values[0].algorithm)
|
||||
a.values[0].algorithm += 1
|
||||
self.assertEqual(3, a.values[0].algorithm)
|
||||
|
||||
self.assertEqual('99148c81', a.values[0].public_key)
|
||||
a.values[0].public_key = '99148c42'
|
||||
self.assertEqual('99148c42', a.values[0].public_key)
|
||||
|
||||
self.assertEqual(1, a.values[1].flags)
|
||||
self.assertEqual(2, a.values[1].protocol)
|
||||
self.assertEqual(3, a.values[1].algorithm)
|
||||
self.assertEqual('99148c44', a.values[1].public_key)
|
||||
|
||||
self.assertEqual(DsValue(values[1]), a.values[1].data)
|
||||
self.assertEqual('1 2 3 99148c44', a.values[1].rdata_text)
|
||||
self.assertEqual('1 2 3 99148c44', a.values[1].__repr__())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record.a import ARecord, Ipv4Value
|
||||
from octodns.zone import Zone
|
||||
|
||||
|
||||
class TestRecordIp(TestCase):
|
||||
def test_ipv4_value_rdata_text(self):
|
||||
|
||||
# anything goes, we're a noop
|
||||
for s in (
|
||||
None,
|
||||
'',
|
||||
'word',
|
||||
42,
|
||||
42.43,
|
||||
'1.2.3',
|
||||
'some.words.that.here',
|
||||
'1.2.word.4',
|
||||
'1.2.3.4',
|
||||
):
|
||||
self.assertEqual(s, Ipv4Value.parse_rdata_text(s))
|
||||
|
||||
zone = Zone('unit.tests.', [])
|
||||
a = ARecord(zone, 'a', {'ttl': 42, 'value': '1.2.3.4'})
|
||||
self.assertEqual('1.2.3.4', a.values[0].rdata_text)
|
||||
@@ -0,0 +1,697 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.record.loc import LocRecord, LocValue
|
||||
from octodns.record.exception import ValidationError
|
||||
from octodns.record.rr import RrParseError
|
||||
from octodns.zone import Zone
|
||||
|
||||
from helpers import SimpleProvider
|
||||
|
||||
|
||||
class TestRecordLoc(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
|
||||
def test_loc(self):
|
||||
a_values = [
|
||||
LocValue(
|
||||
{
|
||||
'lat_degrees': 31,
|
||||
'lat_minutes': 58,
|
||||
'lat_seconds': 52.1,
|
||||
'lat_direction': 'S',
|
||||
'long_degrees': 115,
|
||||
'long_minutes': 49,
|
||||
'long_seconds': 11.7,
|
||||
'long_direction': 'E',
|
||||
'altitude': 20,
|
||||
'size': 10,
|
||||
'precision_horz': 10,
|
||||
'precision_vert': 2,
|
||||
}
|
||||
)
|
||||
]
|
||||
a_data = {'ttl': 30, 'values': a_values}
|
||||
a = LocRecord(self.zone, 'a', a_data)
|
||||
self.assertEqual('a', a.name)
|
||||
self.assertEqual('a.unit.tests.', a.fqdn)
|
||||
self.assertEqual(30, a.ttl)
|
||||
self.assertEqual(a_values[0]['lat_degrees'], a.values[0].lat_degrees)
|
||||
self.assertEqual(a_values[0]['lat_minutes'], a.values[0].lat_minutes)
|
||||
self.assertEqual(a_values[0]['lat_seconds'], a.values[0].lat_seconds)
|
||||
self.assertEqual(
|
||||
a_values[0]['lat_direction'], a.values[0].lat_direction
|
||||
)
|
||||
self.assertEqual(a_values[0]['long_degrees'], a.values[0].long_degrees)
|
||||
self.assertEqual(a_values[0]['long_minutes'], a.values[0].long_minutes)
|
||||
self.assertEqual(a_values[0]['long_seconds'], a.values[0].long_seconds)
|
||||
self.assertEqual(
|
||||
a_values[0]['long_direction'], a.values[0].long_direction
|
||||
)
|
||||
self.assertEqual(a_values[0]['altitude'], a.values[0].altitude)
|
||||
self.assertEqual(a_values[0]['size'], a.values[0].size)
|
||||
self.assertEqual(
|
||||
a_values[0]['precision_horz'], a.values[0].precision_horz
|
||||
)
|
||||
self.assertEqual(
|
||||
a_values[0]['precision_vert'], a.values[0].precision_vert
|
||||
)
|
||||
|
||||
b_value = LocValue(
|
||||
{
|
||||
'lat_degrees': 32,
|
||||
'lat_minutes': 7,
|
||||
'lat_seconds': 19,
|
||||
'lat_direction': 'S',
|
||||
'long_degrees': 116,
|
||||
'long_minutes': 2,
|
||||
'long_seconds': 25,
|
||||
'long_direction': 'E',
|
||||
'altitude': 10,
|
||||
'size': 1,
|
||||
'precision_horz': 10000,
|
||||
'precision_vert': 10,
|
||||
}
|
||||
)
|
||||
b_data = {'ttl': 30, 'value': b_value}
|
||||
b = LocRecord(self.zone, 'b', b_data)
|
||||
self.assertEqual(b_value['lat_degrees'], b.values[0].lat_degrees)
|
||||
self.assertEqual(b_value['lat_minutes'], b.values[0].lat_minutes)
|
||||
self.assertEqual(b_value['lat_seconds'], b.values[0].lat_seconds)
|
||||
self.assertEqual(b_value['lat_direction'], b.values[0].lat_direction)
|
||||
self.assertEqual(b_value['long_degrees'], b.values[0].long_degrees)
|
||||
self.assertEqual(b_value['long_minutes'], b.values[0].long_minutes)
|
||||
self.assertEqual(b_value['long_seconds'], b.values[0].long_seconds)
|
||||
self.assertEqual(b_value['long_direction'], b.values[0].long_direction)
|
||||
self.assertEqual(b_value['altitude'], b.values[0].altitude)
|
||||
self.assertEqual(b_value['size'], b.values[0].size)
|
||||
self.assertEqual(b_value['precision_horz'], b.values[0].precision_horz)
|
||||
self.assertEqual(b_value['precision_vert'], b.values[0].precision_vert)
|
||||
self.assertEqual(b_data, b.data)
|
||||
|
||||
target = SimpleProvider()
|
||||
# No changes with self
|
||||
self.assertFalse(a.changes(a, target))
|
||||
# Diff in lat_direction causes change
|
||||
other = LocRecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
|
||||
other.values[0].lat_direction = 'N'
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
# Diff in altitude causes change
|
||||
other.values[0].altitude = a.values[0].altitude
|
||||
other.values[0].altitude = -10
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
|
||||
# __repr__ doesn't blow up
|
||||
a.__repr__()
|
||||
|
||||
def test_loc_value_rdata_text(self):
|
||||
# only the exact correct number of words is allowed
|
||||
for i in tuple(range(0, 12)) + (13,):
|
||||
s = ''.join(['word'] * i)
|
||||
with self.assertRaises(RrParseError):
|
||||
LocValue.parse_rdata_text(s)
|
||||
|
||||
# type conversions are best effort
|
||||
self.assertEqual(
|
||||
{
|
||||
'altitude': 'six',
|
||||
'lat_degrees': 'zero',
|
||||
'lat_direction': 'S',
|
||||
'lat_minutes': 'one',
|
||||
'lat_seconds': 'two',
|
||||
'long_degrees': 'three',
|
||||
'long_direction': 'W',
|
||||
'long_minutes': 'four',
|
||||
'long_seconds': 'five',
|
||||
'precision_horz': 'eight',
|
||||
'precision_vert': 'nine',
|
||||
'size': 'seven',
|
||||
},
|
||||
LocValue.parse_rdata_text(
|
||||
'zero one two S three four five W six seven eight nine'
|
||||
),
|
||||
)
|
||||
|
||||
# valid
|
||||
s = '0 1 2.2 N 3 4 5.5 E 6.6m 7.7m 8.8m 9.9m'
|
||||
self.assertEqual(
|
||||
{
|
||||
'altitude': 6.6,
|
||||
'lat_degrees': 0,
|
||||
'lat_direction': 'N',
|
||||
'lat_minutes': 1,
|
||||
'lat_seconds': 2.2,
|
||||
'long_degrees': 3,
|
||||
'long_direction': 'E',
|
||||
'long_minutes': 4,
|
||||
'long_seconds': 5.5,
|
||||
'precision_horz': 8.8,
|
||||
'precision_vert': 9.9,
|
||||
'size': 7.7,
|
||||
},
|
||||
LocValue.parse_rdata_text(s),
|
||||
)
|
||||
|
||||
# make sure that the cstor is using parse_rdata_text
|
||||
zone = Zone('unit.tests.', [])
|
||||
a = LocRecord(
|
||||
zone,
|
||||
'mx',
|
||||
{
|
||||
'type': 'LOC',
|
||||
'ttl': 42,
|
||||
'value': {
|
||||
'altitude': 6.6,
|
||||
'lat_degrees': 0,
|
||||
'lat_direction': 'N',
|
||||
'lat_minutes': 1,
|
||||
'lat_seconds': 2.2,
|
||||
'long_degrees': 3,
|
||||
'long_direction': 'E',
|
||||
'long_minutes': 4,
|
||||
'long_seconds': 5.5,
|
||||
'precision_horz': 8.8,
|
||||
'precision_vert': 9.9,
|
||||
'size': 7.7,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(0, a.values[0].lat_degrees)
|
||||
self.assertEqual(1, a.values[0].lat_minutes)
|
||||
self.assertEqual(2.2, a.values[0].lat_seconds)
|
||||
self.assertEqual('N', a.values[0].lat_direction)
|
||||
self.assertEqual(3, a.values[0].long_degrees)
|
||||
self.assertEqual(4, a.values[0].long_minutes)
|
||||
self.assertEqual(5.5, a.values[0].long_seconds)
|
||||
self.assertEqual('E', a.values[0].long_direction)
|
||||
self.assertEqual(6.6, a.values[0].altitude)
|
||||
self.assertEqual(7.7, a.values[0].size)
|
||||
self.assertEqual(8.8, a.values[0].precision_horz)
|
||||
self.assertEqual(9.9, a.values[0].precision_vert)
|
||||
self.assertEqual(s, a.values[0].rdata_text)
|
||||
|
||||
def test_loc_value(self):
|
||||
a = LocValue(
|
||||
{
|
||||
'lat_degrees': 31,
|
||||
'lat_minutes': 58,
|
||||
'lat_seconds': 52.1,
|
||||
'lat_direction': 'S',
|
||||
'long_degrees': 115,
|
||||
'long_minutes': 49,
|
||||
'long_seconds': 11.7,
|
||||
'long_direction': 'E',
|
||||
'altitude': 20,
|
||||
'size': 10,
|
||||
'precision_horz': 10,
|
||||
'precision_vert': 2,
|
||||
}
|
||||
)
|
||||
b = LocValue(
|
||||
{
|
||||
'lat_degrees': 32,
|
||||
'lat_minutes': 7,
|
||||
'lat_seconds': 19,
|
||||
'lat_direction': 'S',
|
||||
'long_degrees': 116,
|
||||
'long_minutes': 2,
|
||||
'long_seconds': 25,
|
||||
'long_direction': 'E',
|
||||
'altitude': 10,
|
||||
'size': 1,
|
||||
'precision_horz': 10000,
|
||||
'precision_vert': 10,
|
||||
}
|
||||
)
|
||||
c = LocValue(
|
||||
{
|
||||
'lat_degrees': 53,
|
||||
'lat_minutes': 14,
|
||||
'lat_seconds': 10,
|
||||
'lat_direction': 'N',
|
||||
'long_degrees': 2,
|
||||
'long_minutes': 18,
|
||||
'long_seconds': 26,
|
||||
'long_direction': 'W',
|
||||
'altitude': 10,
|
||||
'size': 1,
|
||||
'precision_horz': 1000,
|
||||
'precision_vert': 10,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(a, a)
|
||||
self.assertEqual(b, b)
|
||||
self.assertEqual(c, c)
|
||||
|
||||
self.assertNotEqual(a, b)
|
||||
self.assertNotEqual(a, c)
|
||||
self.assertNotEqual(b, a)
|
||||
self.assertNotEqual(b, c)
|
||||
self.assertNotEqual(c, a)
|
||||
self.assertNotEqual(c, b)
|
||||
|
||||
self.assertTrue(a < b)
|
||||
self.assertTrue(a < c)
|
||||
|
||||
self.assertTrue(b > a)
|
||||
self.assertTrue(b < c)
|
||||
|
||||
self.assertTrue(c > a)
|
||||
self.assertTrue(c > b)
|
||||
|
||||
self.assertTrue(a <= b)
|
||||
self.assertTrue(a <= c)
|
||||
self.assertTrue(a <= a)
|
||||
self.assertTrue(a >= a)
|
||||
|
||||
self.assertTrue(b >= a)
|
||||
self.assertTrue(b <= c)
|
||||
self.assertTrue(b >= b)
|
||||
self.assertTrue(b <= b)
|
||||
|
||||
self.assertTrue(c >= a)
|
||||
self.assertTrue(c >= b)
|
||||
self.assertTrue(c >= c)
|
||||
self.assertTrue(c <= c)
|
||||
|
||||
self.assertEqual(31, a.lat_degrees)
|
||||
a.lat_degrees = a.lat_degrees + 1
|
||||
self.assertEqual(32, a.lat_degrees)
|
||||
|
||||
self.assertEqual(58, a.lat_minutes)
|
||||
a.lat_minutes = a.lat_minutes + 1
|
||||
self.assertEqual(59, a.lat_minutes)
|
||||
|
||||
self.assertEqual(52.1, a.lat_seconds)
|
||||
a.lat_seconds = a.lat_seconds + 1
|
||||
self.assertEqual(53.1, a.lat_seconds)
|
||||
|
||||
self.assertEqual('S', a.lat_direction)
|
||||
a.lat_direction = 'N'
|
||||
self.assertEqual('N', a.lat_direction)
|
||||
|
||||
self.assertEqual(115, a.long_degrees)
|
||||
a.long_degrees = a.long_degrees + 1
|
||||
self.assertEqual(116, a.long_degrees)
|
||||
|
||||
self.assertEqual(49, a.long_minutes)
|
||||
a.long_minutes = a.long_minutes + 1
|
||||
self.assertEqual(50, a.long_minutes)
|
||||
|
||||
self.assertEqual(11.7, a.long_seconds)
|
||||
a.long_seconds = a.long_seconds + 1
|
||||
self.assertEqual(12.7, a.long_seconds)
|
||||
|
||||
self.assertEqual('E', a.long_direction)
|
||||
a.long_direction = 'W'
|
||||
self.assertEqual('W', a.long_direction)
|
||||
|
||||
self.assertEqual(20, a.altitude)
|
||||
a.altitude = a.altitude + 1
|
||||
self.assertEqual(21, a.altitude)
|
||||
|
||||
self.assertEqual(10, a.size)
|
||||
a.size = a.size + 1
|
||||
self.assertEqual(11, a.size)
|
||||
|
||||
self.assertEqual(10, a.precision_horz)
|
||||
a.precision_horz = a.precision_horz + 1
|
||||
self.assertEqual(11, a.precision_horz)
|
||||
|
||||
self.assertEqual(2, a.precision_vert)
|
||||
a.precision_vert = a.precision_vert + 1
|
||||
self.assertEqual(3, a.precision_vert)
|
||||
|
||||
# Hash
|
||||
values = set()
|
||||
values.add(a)
|
||||
self.assertTrue(a in values)
|
||||
self.assertFalse(b in values)
|
||||
values.add(b)
|
||||
self.assertTrue(b in values)
|
||||
|
||||
def test_validation(self):
|
||||
# doesn't blow up
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'LOC',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'lat_degrees': 31,
|
||||
'lat_minutes': 58,
|
||||
'lat_seconds': 52.1,
|
||||
'lat_direction': 'S',
|
||||
'long_degrees': 115,
|
||||
'long_minutes': 49,
|
||||
'long_seconds': 11.7,
|
||||
'long_direction': 'E',
|
||||
'altitude': 20,
|
||||
'size': 10,
|
||||
'precision_horz': 10,
|
||||
'precision_vert': 2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# missing int key
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'LOC',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'lat_minutes': 58,
|
||||
'lat_seconds': 52.1,
|
||||
'lat_direction': 'S',
|
||||
'long_degrees': 115,
|
||||
'long_minutes': 49,
|
||||
'long_seconds': 11.7,
|
||||
'long_direction': 'E',
|
||||
'altitude': 20,
|
||||
'size': 10,
|
||||
'precision_horz': 10,
|
||||
'precision_vert': 2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(['missing lat_degrees'], ctx.exception.reasons)
|
||||
|
||||
# missing float key
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'LOC',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'lat_degrees': 31,
|
||||
'lat_minutes': 58,
|
||||
'lat_direction': 'S',
|
||||
'long_degrees': 115,
|
||||
'long_minutes': 49,
|
||||
'long_seconds': 11.7,
|
||||
'long_direction': 'E',
|
||||
'altitude': 20,
|
||||
'size': 10,
|
||||
'precision_horz': 10,
|
||||
'precision_vert': 2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(['missing lat_seconds'], ctx.exception.reasons)
|
||||
|
||||
# missing text key
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'LOC',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'lat_degrees': 31,
|
||||
'lat_minutes': 58,
|
||||
'lat_seconds': 52.1,
|
||||
'long_degrees': 115,
|
||||
'long_minutes': 49,
|
||||
'long_seconds': 11.7,
|
||||
'long_direction': 'E',
|
||||
'altitude': 20,
|
||||
'size': 10,
|
||||
'precision_horz': 10,
|
||||
'precision_vert': 2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(['missing lat_direction'], ctx.exception.reasons)
|
||||
|
||||
# invalid direction
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'LOC',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'lat_degrees': 31,
|
||||
'lat_minutes': 58,
|
||||
'lat_seconds': 52.1,
|
||||
'lat_direction': 'U',
|
||||
'long_degrees': 115,
|
||||
'long_minutes': 49,
|
||||
'long_seconds': 11.7,
|
||||
'long_direction': 'E',
|
||||
'altitude': 20,
|
||||
'size': 10,
|
||||
'precision_horz': 10,
|
||||
'precision_vert': 2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
['invalid direction for lat_direction "U"'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'LOC',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'lat_degrees': 31,
|
||||
'lat_minutes': 58,
|
||||
'lat_seconds': 52.1,
|
||||
'lat_direction': 'S',
|
||||
'long_degrees': 115,
|
||||
'long_minutes': 49,
|
||||
'long_seconds': 11.7,
|
||||
'long_direction': 'N',
|
||||
'altitude': 20,
|
||||
'size': 10,
|
||||
'precision_horz': 10,
|
||||
'precision_vert': 2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
['invalid direction for long_direction "N"'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# invalid degrees
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'LOC',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'lat_degrees': 360,
|
||||
'lat_minutes': 58,
|
||||
'lat_seconds': 52.1,
|
||||
'lat_direction': 'S',
|
||||
'long_degrees': 115,
|
||||
'long_minutes': 49,
|
||||
'long_seconds': 11.7,
|
||||
'long_direction': 'E',
|
||||
'altitude': 20,
|
||||
'size': 10,
|
||||
'precision_horz': 10,
|
||||
'precision_vert': 2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
['invalid value for lat_degrees "360"'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'LOC',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'lat_degrees': 'nope',
|
||||
'lat_minutes': 58,
|
||||
'lat_seconds': 52.1,
|
||||
'lat_direction': 'S',
|
||||
'long_degrees': 115,
|
||||
'long_minutes': 49,
|
||||
'long_seconds': 11.7,
|
||||
'long_direction': 'E',
|
||||
'altitude': 20,
|
||||
'size': 10,
|
||||
'precision_horz': 10,
|
||||
'precision_vert': 2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(['invalid lat_degrees "nope"'], ctx.exception.reasons)
|
||||
|
||||
# invalid minutes
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'LOC',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'lat_degrees': 31,
|
||||
'lat_minutes': 60,
|
||||
'lat_seconds': 52.1,
|
||||
'lat_direction': 'S',
|
||||
'long_degrees': 115,
|
||||
'long_minutes': 49,
|
||||
'long_seconds': 11.7,
|
||||
'long_direction': 'E',
|
||||
'altitude': 20,
|
||||
'size': 10,
|
||||
'precision_horz': 10,
|
||||
'precision_vert': 2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
['invalid value for lat_minutes "60"'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# invalid seconds
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'LOC',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'lat_degrees': 31,
|
||||
'lat_minutes': 58,
|
||||
'lat_seconds': 60,
|
||||
'lat_direction': 'S',
|
||||
'long_degrees': 115,
|
||||
'long_minutes': 49,
|
||||
'long_seconds': 11.7,
|
||||
'long_direction': 'E',
|
||||
'altitude': 20,
|
||||
'size': 10,
|
||||
'precision_horz': 10,
|
||||
'precision_vert': 2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
['invalid value for lat_seconds "60"'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'LOC',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'lat_degrees': 31,
|
||||
'lat_minutes': 58,
|
||||
'lat_seconds': 'nope',
|
||||
'lat_direction': 'S',
|
||||
'long_degrees': 115,
|
||||
'long_minutes': 49,
|
||||
'long_seconds': 11.7,
|
||||
'long_direction': 'E',
|
||||
'altitude': 20,
|
||||
'size': 10,
|
||||
'precision_horz': 10,
|
||||
'precision_vert': 2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(['invalid lat_seconds "nope"'], ctx.exception.reasons)
|
||||
|
||||
# invalid altitude
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'LOC',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'lat_degrees': 31,
|
||||
'lat_minutes': 58,
|
||||
'lat_seconds': 52.1,
|
||||
'lat_direction': 'S',
|
||||
'long_degrees': 115,
|
||||
'long_minutes': 49,
|
||||
'long_seconds': 11.7,
|
||||
'long_direction': 'E',
|
||||
'altitude': -666666,
|
||||
'size': 10,
|
||||
'precision_horz': 10,
|
||||
'precision_vert': 2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
['invalid value for altitude "-666666"'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# invalid size
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'LOC',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'lat_degrees': 31,
|
||||
'lat_minutes': 58,
|
||||
'lat_seconds': 52.1,
|
||||
'lat_direction': 'S',
|
||||
'long_degrees': 115,
|
||||
'long_minutes': 49,
|
||||
'long_seconds': 11.7,
|
||||
'long_direction': 'E',
|
||||
'altitude': 20,
|
||||
'size': 99999999.99,
|
||||
'precision_horz': 10,
|
||||
'precision_vert': 2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
['invalid value for size "99999999.99"'], ctx.exception.reasons
|
||||
)
|
||||
@@ -0,0 +1,265 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.record.mx import MxRecord, MxValue
|
||||
from octodns.record.exception import ValidationError
|
||||
from octodns.record.rr import RrParseError
|
||||
from octodns.zone import Zone
|
||||
|
||||
from helpers import SimpleProvider
|
||||
|
||||
|
||||
class TestRecordMx(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
|
||||
def test_mx(self):
|
||||
a_values = [
|
||||
MxValue({'preference': 10, 'exchange': 'smtp1.'}),
|
||||
MxValue({'priority': 20, 'value': 'smtp2.'}),
|
||||
]
|
||||
a_data = {'ttl': 30, 'values': a_values}
|
||||
a = MxRecord(self.zone, 'a', a_data)
|
||||
self.assertEqual('a', a.name)
|
||||
self.assertEqual('a.unit.tests.', a.fqdn)
|
||||
self.assertEqual(30, a.ttl)
|
||||
self.assertEqual(a_values[0]['preference'], a.values[0].preference)
|
||||
self.assertEqual(a_values[0]['exchange'], a.values[0].exchange)
|
||||
self.assertEqual(a_values[1]['preference'], a.values[1].preference)
|
||||
self.assertEqual(a_values[1]['exchange'], a.values[1].exchange)
|
||||
a_data['values'][1] = MxValue({'preference': 20, 'exchange': 'smtp2.'})
|
||||
self.assertEqual(a_data, a.data)
|
||||
|
||||
b_value = MxValue({'preference': 0, 'exchange': 'smtp3.'})
|
||||
b_data = {'ttl': 30, 'value': b_value}
|
||||
b = MxRecord(self.zone, 'b', b_data)
|
||||
self.assertEqual(b_value['preference'], b.values[0].preference)
|
||||
self.assertEqual(b_value['exchange'], b.values[0].exchange)
|
||||
self.assertEqual(b_data, b.data)
|
||||
|
||||
a_upper_values = [
|
||||
{'preference': 10, 'exchange': 'SMTP1.'},
|
||||
{'priority': 20, 'value': 'SMTP2.'},
|
||||
]
|
||||
a_upper_data = {'ttl': 30, 'values': a_upper_values}
|
||||
a_upper = MxRecord(self.zone, 'a', a_upper_data)
|
||||
self.assertEqual(a_upper.data, a.data)
|
||||
|
||||
target = SimpleProvider()
|
||||
# No changes with self
|
||||
self.assertFalse(a.changes(a, target))
|
||||
# Diff in preference causes change
|
||||
other = MxRecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
|
||||
other.values[0].preference = 22
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
# Diff in value causes change
|
||||
other.values[0].preference = a.values[0].preference
|
||||
other.values[0].exchange = 'smtpX'
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
|
||||
# __repr__ doesn't blow up
|
||||
a.__repr__()
|
||||
|
||||
def test_mx_value_rdata_text(self):
|
||||
|
||||
# empty string won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
MxValue.parse_rdata_text('')
|
||||
|
||||
# single word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
MxValue.parse_rdata_text('nope')
|
||||
|
||||
# 3rd word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
MxValue.parse_rdata_text('10 mx.unit.tests. another')
|
||||
|
||||
# preference not an int
|
||||
self.assertEqual(
|
||||
{'preference': 'abc', 'exchange': 'mx.unit.tests.'},
|
||||
MxValue.parse_rdata_text('abc mx.unit.tests.'),
|
||||
)
|
||||
|
||||
# valid
|
||||
self.assertEqual(
|
||||
{'preference': 10, 'exchange': 'mx.unit.tests.'},
|
||||
MxValue.parse_rdata_text('10 mx.unit.tests.'),
|
||||
)
|
||||
|
||||
zone = Zone('unit.tests.', [])
|
||||
a = MxRecord(
|
||||
zone,
|
||||
'mx',
|
||||
{
|
||||
'ttl': 32,
|
||||
'values': [
|
||||
{'preference': 11, 'exchange': 'mail1.unit.tests.'},
|
||||
{'preference': 12, 'exchange': 'mail2.unit.tests.'},
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertEqual(11, a.values[0].preference)
|
||||
self.assertEqual('mail1.unit.tests.', a.values[0].exchange)
|
||||
self.assertEqual('11 mail1.unit.tests.', a.values[0].rdata_text)
|
||||
self.assertEqual(12, a.values[1].preference)
|
||||
self.assertEqual('mail2.unit.tests.', a.values[1].exchange)
|
||||
self.assertEqual('12 mail2.unit.tests.', a.values[1].rdata_text)
|
||||
|
||||
def test_mx_value(self):
|
||||
a = MxValue(
|
||||
{'preference': 0, 'priority': 'a', 'exchange': 'v', 'value': '1'}
|
||||
)
|
||||
b = MxValue(
|
||||
{'preference': 10, 'priority': 'a', 'exchange': 'v', 'value': '2'}
|
||||
)
|
||||
c = MxValue(
|
||||
{'preference': 0, 'priority': 'b', 'exchange': 'z', 'value': '3'}
|
||||
)
|
||||
|
||||
self.assertEqual(a, a)
|
||||
self.assertEqual(b, b)
|
||||
self.assertEqual(c, c)
|
||||
|
||||
self.assertNotEqual(a, b)
|
||||
self.assertNotEqual(a, c)
|
||||
self.assertNotEqual(b, a)
|
||||
self.assertNotEqual(b, c)
|
||||
self.assertNotEqual(c, a)
|
||||
self.assertNotEqual(c, b)
|
||||
|
||||
self.assertTrue(a < b)
|
||||
self.assertTrue(a < c)
|
||||
|
||||
self.assertTrue(b > a)
|
||||
self.assertTrue(b > c)
|
||||
|
||||
self.assertTrue(c > a)
|
||||
self.assertTrue(c < b)
|
||||
|
||||
self.assertTrue(a <= b)
|
||||
self.assertTrue(a <= c)
|
||||
self.assertTrue(a <= a)
|
||||
self.assertTrue(a >= a)
|
||||
|
||||
self.assertTrue(b >= a)
|
||||
self.assertTrue(b >= c)
|
||||
self.assertTrue(b >= b)
|
||||
self.assertTrue(b <= b)
|
||||
|
||||
self.assertTrue(c >= a)
|
||||
self.assertTrue(c <= b)
|
||||
self.assertTrue(c >= c)
|
||||
self.assertTrue(c <= c)
|
||||
|
||||
self.assertEqual(a.__hash__(), a.__hash__())
|
||||
self.assertNotEqual(a.__hash__(), b.__hash__())
|
||||
|
||||
def test_validation(self):
|
||||
# doesn't blow up
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'MX',
|
||||
'ttl': 600,
|
||||
'value': {'preference': 10, 'exchange': 'foo.bar.com.'},
|
||||
},
|
||||
)
|
||||
|
||||
# missing preference
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'MX',
|
||||
'ttl': 600,
|
||||
'value': {'exchange': 'foo.bar.com.'},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing preference'], ctx.exception.reasons)
|
||||
|
||||
# invalid preference
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'MX',
|
||||
'ttl': 600,
|
||||
'value': {'preference': 'nope', 'exchange': 'foo.bar.com.'},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['invalid preference "nope"'], ctx.exception.reasons)
|
||||
|
||||
# missing exchange
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{'type': 'MX', 'ttl': 600, 'value': {'preference': 10}},
|
||||
)
|
||||
self.assertEqual(['missing exchange'], ctx.exception.reasons)
|
||||
|
||||
# missing trailing .
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'MX',
|
||||
'ttl': 600,
|
||||
'value': {'preference': 10, 'exchange': 'foo.bar.com'},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['MX value "foo.bar.com" missing trailing .'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# exchange must be a valid FQDN
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'MX',
|
||||
'ttl': 600,
|
||||
'value': {'preference': 10, 'exchange': '100 foo.bar.com.'},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['Invalid MX exchange "100 foo.bar.com." is not a valid FQDN.'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
|
||||
# if exchange doesn't exist value can not be None/falsey
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'MX',
|
||||
'ttl': 600,
|
||||
'value': {'preference': 10, 'value': ''},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing exchange'], ctx.exception.reasons)
|
||||
|
||||
# exchange can be a single `.`
|
||||
record = Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'MX',
|
||||
'ttl': 600,
|
||||
'value': {'preference': 0, 'exchange': '.'},
|
||||
},
|
||||
)
|
||||
self.assertEqual('.', record.values[0].exchange)
|
||||
@@ -0,0 +1,438 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.record.naptr import NaptrRecord, NaptrValue
|
||||
from octodns.record.exception import ValidationError
|
||||
from octodns.record.rr import RrParseError
|
||||
from octodns.zone import Zone
|
||||
|
||||
from helpers import SimpleProvider
|
||||
|
||||
|
||||
class TestRecordNaptr(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
|
||||
def test_naptr(self):
|
||||
a_values = [
|
||||
NaptrValue(
|
||||
{
|
||||
'order': 10,
|
||||
'preference': 11,
|
||||
'flags': 'X',
|
||||
'service': 'Y',
|
||||
'regexp': 'Z',
|
||||
'replacement': '.',
|
||||
}
|
||||
),
|
||||
NaptrValue(
|
||||
{
|
||||
'order': 20,
|
||||
'preference': 21,
|
||||
'flags': 'A',
|
||||
'service': 'B',
|
||||
'regexp': 'C',
|
||||
'replacement': 'foo.com',
|
||||
}
|
||||
),
|
||||
]
|
||||
a_data = {'ttl': 30, 'values': a_values}
|
||||
a = NaptrRecord(self.zone, 'a', a_data)
|
||||
self.assertEqual('a', a.name)
|
||||
self.assertEqual('a.unit.tests.', a.fqdn)
|
||||
self.assertEqual(30, a.ttl)
|
||||
for i in (0, 1):
|
||||
for k in a_values[0].keys():
|
||||
self.assertEqual(a_values[i][k], getattr(a.values[i], k))
|
||||
self.assertEqual(a_data, a.data)
|
||||
|
||||
b_value = NaptrValue(
|
||||
{
|
||||
'order': 30,
|
||||
'preference': 31,
|
||||
'flags': 'M',
|
||||
'service': 'N',
|
||||
'regexp': 'O',
|
||||
'replacement': 'x',
|
||||
}
|
||||
)
|
||||
b_data = {'ttl': 30, 'value': b_value}
|
||||
b = NaptrRecord(self.zone, 'b', b_data)
|
||||
for k in a_values[0].keys():
|
||||
self.assertEqual(b_value[k], getattr(b.values[0], k))
|
||||
self.assertEqual(b_data, b.data)
|
||||
|
||||
target = SimpleProvider()
|
||||
# No changes with self
|
||||
self.assertFalse(a.changes(a, target))
|
||||
# Diff in priority causes change
|
||||
other = NaptrRecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
|
||||
other.values[0].order = 22
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
# Diff in replacement causes change
|
||||
other.values[0].order = a.values[0].order
|
||||
other.values[0].replacement = 'smtpX'
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
|
||||
# full sorting
|
||||
# equivalent
|
||||
b_naptr_value = b.values[0]
|
||||
self.assertTrue(b_naptr_value == b_naptr_value)
|
||||
self.assertFalse(b_naptr_value != b_naptr_value)
|
||||
self.assertTrue(b_naptr_value <= b_naptr_value)
|
||||
self.assertTrue(b_naptr_value >= b_naptr_value)
|
||||
# by order
|
||||
self.assertTrue(
|
||||
b_naptr_value
|
||||
> NaptrValue(
|
||||
{
|
||||
'order': 10,
|
||||
'preference': 31,
|
||||
'flags': 'M',
|
||||
'service': 'N',
|
||||
'regexp': 'O',
|
||||
'replacement': 'x',
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
b_naptr_value
|
||||
< NaptrValue(
|
||||
{
|
||||
'order': 40,
|
||||
'preference': 31,
|
||||
'flags': 'M',
|
||||
'service': 'N',
|
||||
'regexp': 'O',
|
||||
'replacement': 'x',
|
||||
}
|
||||
)
|
||||
)
|
||||
# by preference
|
||||
self.assertTrue(
|
||||
b_naptr_value
|
||||
> NaptrValue(
|
||||
{
|
||||
'order': 30,
|
||||
'preference': 10,
|
||||
'flags': 'M',
|
||||
'service': 'N',
|
||||
'regexp': 'O',
|
||||
'replacement': 'x',
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
b_naptr_value
|
||||
< NaptrValue(
|
||||
{
|
||||
'order': 30,
|
||||
'preference': 40,
|
||||
'flags': 'M',
|
||||
'service': 'N',
|
||||
'regexp': 'O',
|
||||
'replacement': 'x',
|
||||
}
|
||||
)
|
||||
)
|
||||
# by flags
|
||||
self.assertTrue(
|
||||
b_naptr_value
|
||||
> NaptrValue(
|
||||
{
|
||||
'order': 30,
|
||||
'preference': 31,
|
||||
'flags': 'A',
|
||||
'service': 'N',
|
||||
'regexp': 'O',
|
||||
'replacement': 'x',
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
b_naptr_value
|
||||
< NaptrValue(
|
||||
{
|
||||
'order': 30,
|
||||
'preference': 31,
|
||||
'flags': 'Z',
|
||||
'service': 'N',
|
||||
'regexp': 'O',
|
||||
'replacement': 'x',
|
||||
}
|
||||
)
|
||||
)
|
||||
# by service
|
||||
self.assertTrue(
|
||||
b_naptr_value
|
||||
> NaptrValue(
|
||||
{
|
||||
'order': 30,
|
||||
'preference': 31,
|
||||
'flags': 'M',
|
||||
'service': 'A',
|
||||
'regexp': 'O',
|
||||
'replacement': 'x',
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
b_naptr_value
|
||||
< NaptrValue(
|
||||
{
|
||||
'order': 30,
|
||||
'preference': 31,
|
||||
'flags': 'M',
|
||||
'service': 'Z',
|
||||
'regexp': 'O',
|
||||
'replacement': 'x',
|
||||
}
|
||||
)
|
||||
)
|
||||
# by regexp
|
||||
self.assertTrue(
|
||||
b_naptr_value
|
||||
> NaptrValue(
|
||||
{
|
||||
'order': 30,
|
||||
'preference': 31,
|
||||
'flags': 'M',
|
||||
'service': 'N',
|
||||
'regexp': 'A',
|
||||
'replacement': 'x',
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
b_naptr_value
|
||||
< NaptrValue(
|
||||
{
|
||||
'order': 30,
|
||||
'preference': 31,
|
||||
'flags': 'M',
|
||||
'service': 'N',
|
||||
'regexp': 'Z',
|
||||
'replacement': 'x',
|
||||
}
|
||||
)
|
||||
)
|
||||
# by replacement
|
||||
self.assertTrue(
|
||||
b_naptr_value
|
||||
> NaptrValue(
|
||||
{
|
||||
'order': 30,
|
||||
'preference': 31,
|
||||
'flags': 'M',
|
||||
'service': 'N',
|
||||
'regexp': 'O',
|
||||
'replacement': 'a',
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
b_naptr_value
|
||||
< NaptrValue(
|
||||
{
|
||||
'order': 30,
|
||||
'preference': 31,
|
||||
'flags': 'M',
|
||||
'service': 'N',
|
||||
'regexp': 'O',
|
||||
'replacement': 'z',
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# __repr__ doesn't blow up
|
||||
a.__repr__()
|
||||
|
||||
# Hash
|
||||
v = NaptrValue(
|
||||
{
|
||||
'order': 30,
|
||||
'preference': 31,
|
||||
'flags': 'M',
|
||||
'service': 'N',
|
||||
'regexp': 'O',
|
||||
'replacement': 'z',
|
||||
}
|
||||
)
|
||||
o = NaptrValue(
|
||||
{
|
||||
'order': 30,
|
||||
'preference': 32,
|
||||
'flags': 'M',
|
||||
'service': 'N',
|
||||
'regexp': 'O',
|
||||
'replacement': 'z',
|
||||
}
|
||||
)
|
||||
values = set()
|
||||
values.add(v)
|
||||
self.assertTrue(v in values)
|
||||
self.assertFalse(o in values)
|
||||
values.add(o)
|
||||
self.assertTrue(o in values)
|
||||
|
||||
self.assertEqual(30, o.order)
|
||||
o.order = o.order + 1
|
||||
self.assertEqual(31, o.order)
|
||||
|
||||
self.assertEqual(32, o.preference)
|
||||
o.preference = o.preference + 1
|
||||
self.assertEqual(33, o.preference)
|
||||
|
||||
self.assertEqual('M', o.flags)
|
||||
o.flags = 'P'
|
||||
self.assertEqual('P', o.flags)
|
||||
|
||||
self.assertEqual('N', o.service)
|
||||
o.service = 'Q'
|
||||
self.assertEqual('Q', o.service)
|
||||
|
||||
self.assertEqual('O', o.regexp)
|
||||
o.regexp = 'R'
|
||||
self.assertEqual('R', o.regexp)
|
||||
|
||||
self.assertEqual('z', o.replacement)
|
||||
o.replacement = '1'
|
||||
self.assertEqual('1', o.replacement)
|
||||
|
||||
def test_naptr_value_rdata_text(self):
|
||||
# things with the wrong number of words won't parse
|
||||
for v in (
|
||||
'',
|
||||
'one',
|
||||
'one two',
|
||||
'one two three',
|
||||
'one two three four',
|
||||
'one two three four five',
|
||||
'one two three four five six seven',
|
||||
):
|
||||
with self.assertRaises(RrParseError):
|
||||
NaptrValue.parse_rdata_text(v)
|
||||
|
||||
# we don't care if the types of things are correct when parsing rr text
|
||||
self.assertEqual(
|
||||
{
|
||||
'order': 'one',
|
||||
'preference': 'two',
|
||||
'flags': 'three',
|
||||
'service': 'four',
|
||||
'regexp': 'five',
|
||||
'replacement': 'six',
|
||||
},
|
||||
NaptrValue.parse_rdata_text('one two three four five six'),
|
||||
)
|
||||
|
||||
# order and preference will be converted to int's when possible
|
||||
self.assertEqual(
|
||||
{
|
||||
'order': 1,
|
||||
'preference': 2,
|
||||
'flags': 'three',
|
||||
'service': 'four',
|
||||
'regexp': 'five',
|
||||
'replacement': 'six',
|
||||
},
|
||||
NaptrValue.parse_rdata_text('1 2 three four five six'),
|
||||
)
|
||||
|
||||
# make sure that the cstor is using parse_rdata_text
|
||||
zone = Zone('unit.tests.', [])
|
||||
a = NaptrRecord(
|
||||
zone,
|
||||
'naptr',
|
||||
{
|
||||
'ttl': 32,
|
||||
'value': {
|
||||
'order': 1,
|
||||
'preference': 2,
|
||||
'flags': 'S',
|
||||
'service': 'service',
|
||||
'regexp': 'regexp',
|
||||
'replacement': 'replacement',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(1, a.values[0].order)
|
||||
self.assertEqual(2, a.values[0].preference)
|
||||
self.assertEqual('S', a.values[0].flags)
|
||||
self.assertEqual('service', a.values[0].service)
|
||||
self.assertEqual('regexp', a.values[0].regexp)
|
||||
self.assertEqual('replacement', a.values[0].replacement)
|
||||
s = '1 2 S service regexp replacement'
|
||||
self.assertEqual(s, a.values[0].rdata_text)
|
||||
|
||||
def test_validation(self):
|
||||
# doesn't blow up
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'NAPTR',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'order': 10,
|
||||
'preference': 20,
|
||||
'flags': 'S',
|
||||
'service': 'srv',
|
||||
'regexp': '.*',
|
||||
'replacement': '.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# missing X priority
|
||||
value = {
|
||||
'order': 10,
|
||||
'preference': 20,
|
||||
'flags': 'S',
|
||||
'service': 'srv',
|
||||
'regexp': '.*',
|
||||
'replacement': '.',
|
||||
}
|
||||
for k in (
|
||||
'order',
|
||||
'preference',
|
||||
'flags',
|
||||
'service',
|
||||
'regexp',
|
||||
'replacement',
|
||||
):
|
||||
v = dict(value)
|
||||
del v[k]
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, '', {'type': 'NAPTR', 'ttl': 600, 'value': v}
|
||||
)
|
||||
self.assertEqual([f'missing {k}'], ctx.exception.reasons)
|
||||
|
||||
# non-int order
|
||||
v = dict(value)
|
||||
v['order'] = 'boo'
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {'type': 'NAPTR', 'ttl': 600, 'value': v})
|
||||
self.assertEqual(['invalid order "boo"'], ctx.exception.reasons)
|
||||
|
||||
# non-int preference
|
||||
v = dict(value)
|
||||
v['preference'] = 'who'
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {'type': 'NAPTR', 'ttl': 600, 'value': v})
|
||||
self.assertEqual(['invalid preference "who"'], ctx.exception.reasons)
|
||||
|
||||
# unrecognized flags
|
||||
v = dict(value)
|
||||
v['flags'] = 'X'
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {'type': 'NAPTR', 'ttl': 600, 'value': v})
|
||||
self.assertEqual(['unrecognized flags "X"'], ctx.exception.reasons)
|
||||
@@ -0,0 +1,83 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.record.ns import NsRecord, NsValue
|
||||
from octodns.record.exception import ValidationError
|
||||
from octodns.zone import Zone
|
||||
|
||||
|
||||
class TestRecordNs(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
|
||||
def test_ns(self):
|
||||
a_values = ['5.6.7.8.', '6.7.8.9.', '7.8.9.0.']
|
||||
a_data = {'ttl': 30, 'values': a_values}
|
||||
a = NsRecord(self.zone, 'a', a_data)
|
||||
self.assertEqual('a', a.name)
|
||||
self.assertEqual('a.unit.tests.', a.fqdn)
|
||||
self.assertEqual(30, a.ttl)
|
||||
self.assertEqual(a_values, a.values)
|
||||
self.assertEqual(a_data, a.data)
|
||||
|
||||
b_value = '9.8.7.6.'
|
||||
b_data = {'ttl': 30, 'value': b_value}
|
||||
b = NsRecord(self.zone, 'b', b_data)
|
||||
self.assertEqual([b_value], b.values)
|
||||
self.assertEqual(b_data, b.data)
|
||||
|
||||
def test_ns_value_rdata_text(self):
|
||||
# anything goes, we're a noop
|
||||
for s in (
|
||||
None,
|
||||
'',
|
||||
'word',
|
||||
42,
|
||||
42.43,
|
||||
'1.2.3',
|
||||
'some.words.that.here',
|
||||
'1.2.word.4',
|
||||
'1.2.3.4',
|
||||
):
|
||||
self.assertEqual(s, NsValue.parse_rdata_text(s))
|
||||
|
||||
zone = Zone('unit.tests.', [])
|
||||
a = NsRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'})
|
||||
self.assertEqual('some.target.', a.values[0].rdata_text)
|
||||
|
||||
def test_validation(self):
|
||||
# doesn't blow up
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{'type': 'NS', 'ttl': 600, 'values': ['foo.bar.com.', '1.2.3.4.']},
|
||||
)
|
||||
|
||||
# missing value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {'type': 'NS', 'ttl': 600})
|
||||
self.assertEqual(['missing value(s)'], ctx.exception.reasons)
|
||||
|
||||
# no trailing .
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, '', {'type': 'NS', 'ttl': 600, 'value': 'foo.bar'}
|
||||
)
|
||||
self.assertEqual(
|
||||
['NS value "foo.bar" missing trailing .'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# exchange must be a valid FQDN
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{'type': 'NS', 'ttl': 600, 'value': '100 foo.bar.com.'},
|
||||
)
|
||||
self.assertEqual(
|
||||
['Invalid NS value "100 foo.bar.com." is not a valid FQDN.'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.record.ptr import PtrRecord, PtrValue
|
||||
from octodns.record.exception import ValidationError
|
||||
from octodns.zone import Zone
|
||||
|
||||
|
||||
class TestRecordPtr(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
|
||||
def test_ptr_lowering_value(self):
|
||||
upper_record = PtrRecord(
|
||||
self.zone,
|
||||
'PtrUppwerValue',
|
||||
{'ttl': 30, 'type': 'PTR', 'value': 'GITHUB.COM.'},
|
||||
)
|
||||
lower_record = PtrRecord(
|
||||
self.zone,
|
||||
'PtrLowerValue',
|
||||
{'ttl': 30, 'type': 'PTR', 'value': 'github.com.'},
|
||||
)
|
||||
self.assertEqual(upper_record.value, lower_record.value)
|
||||
|
||||
def test_ptr(self):
|
||||
# doesn't blow up (name & zone here don't make any sense, but not
|
||||
# important)
|
||||
Record.new(
|
||||
self.zone, '', {'type': 'PTR', 'ttl': 600, 'value': 'foo.bar.com.'}
|
||||
)
|
||||
|
||||
# missing value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {'type': 'PTR', 'ttl': 600})
|
||||
self.assertEqual(['missing value(s)'], ctx.exception.reasons)
|
||||
|
||||
# empty value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {'type': 'PTR', 'ttl': 600, 'value': ''})
|
||||
self.assertEqual(['missing value(s)'], ctx.exception.reasons)
|
||||
|
||||
# not a valid FQDN
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, '', {'type': 'PTR', 'ttl': 600, 'value': '_.'}
|
||||
)
|
||||
self.assertEqual(
|
||||
['Invalid PTR value "_." is not a valid FQDN.'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
|
||||
# no trailing .
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone, '', {'type': 'PTR', 'ttl': 600, 'value': 'foo.bar'}
|
||||
)
|
||||
self.assertEqual(
|
||||
['PTR value "foo.bar" missing trailing .'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
def test_ptr_rdata_text(self):
|
||||
|
||||
# anything goes, we're a noop
|
||||
for s in (
|
||||
None,
|
||||
'',
|
||||
'word',
|
||||
42,
|
||||
42.43,
|
||||
'1.2.3',
|
||||
'some.words.that.here',
|
||||
'1.2.word.4',
|
||||
'1.2.3.4',
|
||||
):
|
||||
self.assertEqual(s, PtrValue.parse_rdata_text(s))
|
||||
|
||||
zone = Zone('unit.tests.', [])
|
||||
a = PtrRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'})
|
||||
self.assertEqual('some.target.', a.values[0].rdata_text)
|
||||
|
||||
a = PtrRecord(
|
||||
zone, 'a', {'ttl': 42, 'values': ['some.target.', 'second.target.']}
|
||||
)
|
||||
self.assertEqual('second.target.', a.values[0].rdata_text)
|
||||
self.assertEqual('some.target.', a.values[1].rdata_text)
|
||||
@@ -0,0 +1,71 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.record.spf import SpfRecord
|
||||
from octodns.record.exception import ValidationError
|
||||
from octodns.zone import Zone
|
||||
|
||||
|
||||
class TestRecordSpf(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
|
||||
def assertMultipleValues(self, _type, a_values, b_value):
|
||||
a_data = {'ttl': 30, 'values': a_values}
|
||||
a = _type(self.zone, 'a', a_data)
|
||||
self.assertEqual('a', a.name)
|
||||
self.assertEqual('a.unit.tests.', a.fqdn)
|
||||
self.assertEqual(30, a.ttl)
|
||||
self.assertEqual(a_values, a.values)
|
||||
self.assertEqual(a_data, a.data)
|
||||
|
||||
b_data = {'ttl': 30, 'value': b_value}
|
||||
b = _type(self.zone, 'b', b_data)
|
||||
self.assertEqual([b_value], b.values)
|
||||
self.assertEqual(b_data, b.data)
|
||||
|
||||
def test_spf(self):
|
||||
a_values = ['spf1 -all', 'spf1 -hrm']
|
||||
b_value = 'spf1 -other'
|
||||
self.assertMultipleValues(SpfRecord, a_values, b_value)
|
||||
|
||||
def test_validation(self):
|
||||
# doesn't blow up (name & zone here don't make any sense, but not
|
||||
# important)
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'SPF',
|
||||
'ttl': 600,
|
||||
'values': [
|
||||
'v=spf1 ip4:192.168.0.1/16-all',
|
||||
'v=spf1 ip4:10.1.2.1/24-all',
|
||||
'this has some\\; semi-colons\\; in it',
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
# missing value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {'type': 'SPF', 'ttl': 600})
|
||||
self.assertEqual(['missing value(s)'], ctx.exception.reasons)
|
||||
|
||||
# missing escapes
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'SPF',
|
||||
'ttl': 600,
|
||||
'value': 'this has some; semi-colons\\; in it',
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['unescaped ; in "this has some; semi-colons\\; in it"'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
@@ -0,0 +1,432 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.record.srv import SrvRecord, SrvValue
|
||||
from octodns.record.exception import ValidationError
|
||||
from octodns.record.rr import RrParseError
|
||||
from octodns.zone import Zone
|
||||
|
||||
from helpers import SimpleProvider
|
||||
|
||||
|
||||
class TestRecordSrv(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
|
||||
def test_srv(self):
|
||||
a_values = [
|
||||
SrvValue(
|
||||
{'priority': 10, 'weight': 11, 'port': 12, 'target': 'server1'}
|
||||
),
|
||||
SrvValue(
|
||||
{'priority': 20, 'weight': 21, 'port': 22, 'target': 'server2'}
|
||||
),
|
||||
]
|
||||
a_data = {'ttl': 30, 'values': a_values}
|
||||
a = SrvRecord(self.zone, '_a._tcp', a_data)
|
||||
self.assertEqual('_a._tcp', a.name)
|
||||
self.assertEqual('_a._tcp.unit.tests.', a.fqdn)
|
||||
self.assertEqual(30, a.ttl)
|
||||
self.assertEqual(a_values[0]['priority'], a.values[0].priority)
|
||||
self.assertEqual(a_values[0]['weight'], a.values[0].weight)
|
||||
self.assertEqual(a_values[0]['port'], a.values[0].port)
|
||||
self.assertEqual(a_values[0]['target'], a.values[0].target)
|
||||
self.assertEqual(a_data, a.data)
|
||||
|
||||
b_value = SrvValue(
|
||||
{'priority': 30, 'weight': 31, 'port': 32, 'target': 'server3'}
|
||||
)
|
||||
b_data = {'ttl': 30, 'value': b_value}
|
||||
b = SrvRecord(self.zone, '_b._tcp', b_data)
|
||||
self.assertEqual(b_value['priority'], b.values[0].priority)
|
||||
self.assertEqual(b_value['weight'], b.values[0].weight)
|
||||
self.assertEqual(b_value['port'], b.values[0].port)
|
||||
self.assertEqual(b_value['target'], b.values[0].target)
|
||||
self.assertEqual(b_data, b.data)
|
||||
|
||||
target = SimpleProvider()
|
||||
# No changes with self
|
||||
self.assertFalse(a.changes(a, target))
|
||||
# Diff in priority causes change
|
||||
other = SrvRecord(
|
||||
self.zone, '_a._icmp', {'ttl': 30, 'values': a_values}
|
||||
)
|
||||
other.values[0].priority = 22
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
# Diff in weight causes change
|
||||
other.values[0].priority = a.values[0].priority
|
||||
other.values[0].weight = 33
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
# Diff in port causes change
|
||||
other.values[0].weight = a.values[0].weight
|
||||
other.values[0].port = 44
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
# Diff in target causes change
|
||||
other.values[0].port = a.values[0].port
|
||||
other.values[0].target = 'serverX'
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
|
||||
# __repr__ doesn't blow up
|
||||
a.__repr__()
|
||||
|
||||
def test_srv_value_rdata_text(self):
|
||||
|
||||
# empty string won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
SrvValue.parse_rdata_text('')
|
||||
|
||||
# single word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
SrvValue.parse_rdata_text('nope')
|
||||
|
||||
# 2nd word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
SrvValue.parse_rdata_text('1 2')
|
||||
|
||||
# 3rd word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
SrvValue.parse_rdata_text('1 2 3')
|
||||
|
||||
# 5th word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
SrvValue.parse_rdata_text('1 2 3 4 5')
|
||||
|
||||
# priority weight and port not ints
|
||||
self.assertEqual(
|
||||
{
|
||||
'priority': 'one',
|
||||
'weight': 'two',
|
||||
'port': 'three',
|
||||
'target': 'srv.unit.tests.',
|
||||
},
|
||||
SrvValue.parse_rdata_text('one two three srv.unit.tests.'),
|
||||
)
|
||||
|
||||
# valid
|
||||
self.assertEqual(
|
||||
{
|
||||
'priority': 1,
|
||||
'weight': 2,
|
||||
'port': 3,
|
||||
'target': 'srv.unit.tests.',
|
||||
},
|
||||
SrvValue.parse_rdata_text('1 2 3 srv.unit.tests.'),
|
||||
)
|
||||
|
||||
zone = Zone('unit.tests.', [])
|
||||
a = SrvRecord(
|
||||
zone,
|
||||
'_srv._tcp',
|
||||
{
|
||||
'ttl': 32,
|
||||
'value': {
|
||||
'priority': 1,
|
||||
'weight': 2,
|
||||
'port': 3,
|
||||
'target': 'srv.unit.tests.',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(1, a.values[0].priority)
|
||||
self.assertEqual(2, a.values[0].weight)
|
||||
self.assertEqual(3, a.values[0].port)
|
||||
self.assertEqual('srv.unit.tests.', a.values[0].target)
|
||||
|
||||
def test_srv_value(self):
|
||||
a = SrvValue({'priority': 0, 'weight': 0, 'port': 0, 'target': 'foo.'})
|
||||
b = SrvValue({'priority': 1, 'weight': 0, 'port': 0, 'target': 'foo.'})
|
||||
c = SrvValue({'priority': 0, 'weight': 2, 'port': 0, 'target': 'foo.'})
|
||||
d = SrvValue({'priority': 0, 'weight': 0, 'port': 3, 'target': 'foo.'})
|
||||
e = SrvValue({'priority': 0, 'weight': 0, 'port': 0, 'target': 'mmm.'})
|
||||
|
||||
self.assertEqual(a, a)
|
||||
self.assertEqual(b, b)
|
||||
self.assertEqual(c, c)
|
||||
self.assertEqual(d, d)
|
||||
self.assertEqual(e, e)
|
||||
|
||||
self.assertNotEqual(a, b)
|
||||
self.assertNotEqual(a, c)
|
||||
self.assertNotEqual(a, d)
|
||||
self.assertNotEqual(a, e)
|
||||
self.assertNotEqual(b, a)
|
||||
self.assertNotEqual(b, c)
|
||||
self.assertNotEqual(b, d)
|
||||
self.assertNotEqual(b, e)
|
||||
self.assertNotEqual(c, a)
|
||||
self.assertNotEqual(c, b)
|
||||
self.assertNotEqual(c, d)
|
||||
self.assertNotEqual(c, e)
|
||||
self.assertNotEqual(d, a)
|
||||
self.assertNotEqual(d, b)
|
||||
self.assertNotEqual(d, c)
|
||||
self.assertNotEqual(d, e)
|
||||
self.assertNotEqual(e, a)
|
||||
self.assertNotEqual(e, b)
|
||||
self.assertNotEqual(e, c)
|
||||
self.assertNotEqual(e, d)
|
||||
|
||||
self.assertTrue(a < b)
|
||||
self.assertTrue(a < c)
|
||||
|
||||
self.assertTrue(b > a)
|
||||
self.assertTrue(b > c)
|
||||
|
||||
self.assertTrue(c > a)
|
||||
self.assertTrue(c < b)
|
||||
|
||||
self.assertTrue(a <= b)
|
||||
self.assertTrue(a <= c)
|
||||
self.assertTrue(a <= a)
|
||||
self.assertTrue(a >= a)
|
||||
|
||||
self.assertTrue(b >= a)
|
||||
self.assertTrue(b >= c)
|
||||
self.assertTrue(b >= b)
|
||||
self.assertTrue(b <= b)
|
||||
|
||||
self.assertTrue(c >= a)
|
||||
self.assertTrue(c <= b)
|
||||
self.assertTrue(c >= c)
|
||||
self.assertTrue(c <= c)
|
||||
|
||||
# Hash
|
||||
values = set()
|
||||
values.add(a)
|
||||
self.assertTrue(a in values)
|
||||
self.assertFalse(b in values)
|
||||
values.add(b)
|
||||
self.assertTrue(b in values)
|
||||
|
||||
def test_valiation(self):
|
||||
# doesn't blow up
|
||||
Record.new(
|
||||
self.zone,
|
||||
'_srv._tcp',
|
||||
{
|
||||
'type': 'SRV',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'priority': 1,
|
||||
'weight': 2,
|
||||
'port': 3,
|
||||
'target': 'foo.bar.baz.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# permit wildcard entries
|
||||
Record.new(
|
||||
self.zone,
|
||||
'*._tcp',
|
||||
{
|
||||
'type': 'SRV',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'priority': 1,
|
||||
'weight': 2,
|
||||
'port': 3,
|
||||
'target': 'food.bar.baz.',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# invalid name
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'neup',
|
||||
{
|
||||
'type': 'SRV',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'priority': 1,
|
||||
'weight': 2,
|
||||
'port': 3,
|
||||
'target': 'foo.bar.baz.',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['invalid name for SRV record'], ctx.exception.reasons)
|
||||
|
||||
# missing priority
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'_srv._tcp',
|
||||
{
|
||||
'type': 'SRV',
|
||||
'ttl': 600,
|
||||
'value': {'weight': 2, 'port': 3, 'target': 'foo.bar.baz.'},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing priority'], ctx.exception.reasons)
|
||||
|
||||
# invalid priority
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'_srv._tcp',
|
||||
{
|
||||
'type': 'SRV',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'priority': 'foo',
|
||||
'weight': 2,
|
||||
'port': 3,
|
||||
'target': 'foo.bar.baz.',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['invalid priority "foo"'], ctx.exception.reasons)
|
||||
|
||||
# missing weight
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'_srv._tcp',
|
||||
{
|
||||
'type': 'SRV',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'priority': 1,
|
||||
'port': 3,
|
||||
'target': 'foo.bar.baz.',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing weight'], ctx.exception.reasons)
|
||||
# invalid weight
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'_srv._tcp',
|
||||
{
|
||||
'type': 'SRV',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'priority': 1,
|
||||
'weight': 'foo',
|
||||
'port': 3,
|
||||
'target': 'foo.bar.baz.',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['invalid weight "foo"'], ctx.exception.reasons)
|
||||
|
||||
# missing port
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'_srv._tcp',
|
||||
{
|
||||
'type': 'SRV',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'priority': 1,
|
||||
'weight': 2,
|
||||
'target': 'foo.bar.baz.',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing port'], ctx.exception.reasons)
|
||||
# invalid port
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'_srv._tcp',
|
||||
{
|
||||
'type': 'SRV',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'priority': 1,
|
||||
'weight': 2,
|
||||
'port': 'foo',
|
||||
'target': 'foo.bar.baz.',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['invalid port "foo"'], ctx.exception.reasons)
|
||||
|
||||
# missing target
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'_srv._tcp',
|
||||
{
|
||||
'type': 'SRV',
|
||||
'ttl': 600,
|
||||
'value': {'priority': 1, 'weight': 2, 'port': 3},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing target'], ctx.exception.reasons)
|
||||
# invalid target
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'_srv._tcp',
|
||||
{
|
||||
'type': 'SRV',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'priority': 1,
|
||||
'weight': 2,
|
||||
'port': 3,
|
||||
'target': 'foo.bar.baz',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['SRV value "foo.bar.baz" missing trailing .'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
|
||||
# falsey target
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'_srv._tcp',
|
||||
{
|
||||
'type': 'SRV',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'priority': 1,
|
||||
'weight': 2,
|
||||
'port': 3,
|
||||
'target': '',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing target'], ctx.exception.reasons)
|
||||
|
||||
# target must be a valid FQDN
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'_srv._tcp',
|
||||
{
|
||||
'type': 'SRV',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'priority': 1,
|
||||
'weight': 2,
|
||||
'port': 3,
|
||||
'target': '100 foo.bar.com.',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['Invalid SRV target "100 foo.bar.com." is not a valid FQDN.'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
@@ -0,0 +1,330 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record.base import Record
|
||||
from octodns.record.exception import ValidationError
|
||||
from octodns.record.sshfp import SshfpRecord, SshfpValue
|
||||
from octodns.record.rr import RrParseError
|
||||
from octodns.zone import Zone
|
||||
|
||||
from helpers import SimpleProvider
|
||||
|
||||
|
||||
class TestRecordSshfp(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
|
||||
def test_sshfp(self):
|
||||
a_values = [
|
||||
SshfpValue(
|
||||
{
|
||||
'algorithm': 10,
|
||||
'fingerprint_type': 11,
|
||||
'fingerprint': 'abc123',
|
||||
}
|
||||
),
|
||||
SshfpValue(
|
||||
{
|
||||
'algorithm': 20,
|
||||
'fingerprint_type': 21,
|
||||
'fingerprint': 'def456',
|
||||
}
|
||||
),
|
||||
]
|
||||
a_data = {'ttl': 30, 'values': a_values}
|
||||
a = SshfpRecord(self.zone, 'a', a_data)
|
||||
self.assertEqual('a', a.name)
|
||||
self.assertEqual('a.unit.tests.', a.fqdn)
|
||||
self.assertEqual(30, a.ttl)
|
||||
self.assertEqual(a_values[0]['algorithm'], a.values[0].algorithm)
|
||||
self.assertEqual(
|
||||
a_values[0]['fingerprint_type'], a.values[0].fingerprint_type
|
||||
)
|
||||
self.assertEqual(a_values[0]['fingerprint'], a.values[0].fingerprint)
|
||||
self.assertEqual(a_data, a.data)
|
||||
|
||||
b_value = SshfpValue(
|
||||
{'algorithm': 30, 'fingerprint_type': 31, 'fingerprint': 'ghi789'}
|
||||
)
|
||||
b_data = {'ttl': 30, 'value': b_value}
|
||||
b = SshfpRecord(self.zone, 'b', b_data)
|
||||
self.assertEqual(b_value['algorithm'], b.values[0].algorithm)
|
||||
self.assertEqual(
|
||||
b_value['fingerprint_type'], b.values[0].fingerprint_type
|
||||
)
|
||||
self.assertEqual(b_value['fingerprint'], b.values[0].fingerprint)
|
||||
self.assertEqual(b_data, b.data)
|
||||
|
||||
target = SimpleProvider()
|
||||
# No changes with self
|
||||
self.assertFalse(a.changes(a, target))
|
||||
# Diff in algorithm causes change
|
||||
other = SshfpRecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
|
||||
other.values[0].algorithm = 22
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
# Diff in fingerprint_type causes change
|
||||
other = SshfpRecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
|
||||
other.values[0].algorithm = a.values[0].algorithm
|
||||
other.values[0].fingerprint_type = 22
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
# Diff in fingerprint causes change
|
||||
other = SshfpRecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
|
||||
other.values[0].fingerprint_type = a.values[0].fingerprint_type
|
||||
other.values[0].fingerprint = 22
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
|
||||
# __repr__ doesn't blow up
|
||||
a.__repr__()
|
||||
|
||||
def test_sshfp_value_rdata_text(self):
|
||||
|
||||
# empty string won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
SshfpValue.parse_rdata_text('')
|
||||
|
||||
# single word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
SshfpValue.parse_rdata_text('nope')
|
||||
|
||||
# 3rd word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
SshfpValue.parse_rdata_text('0 1 00479b27 another')
|
||||
|
||||
# algorithm and fingerprint_type not ints
|
||||
self.assertEqual(
|
||||
{
|
||||
'algorithm': 'one',
|
||||
'fingerprint_type': 'two',
|
||||
'fingerprint': '00479b27',
|
||||
},
|
||||
SshfpValue.parse_rdata_text('one two 00479b27'),
|
||||
)
|
||||
|
||||
# valid
|
||||
self.assertEqual(
|
||||
{'algorithm': 1, 'fingerprint_type': 2, 'fingerprint': '00479b27'},
|
||||
SshfpValue.parse_rdata_text('1 2 00479b27'),
|
||||
)
|
||||
|
||||
zone = Zone('unit.tests.', [])
|
||||
a = SshfpRecord(
|
||||
zone,
|
||||
'sshfp',
|
||||
{
|
||||
'ttl': 32,
|
||||
'value': {
|
||||
'algorithm': 1,
|
||||
'fingerprint_type': 2,
|
||||
'fingerprint': '00479b27',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(1, a.values[0].algorithm)
|
||||
self.assertEqual(2, a.values[0].fingerprint_type)
|
||||
self.assertEqual('00479b27', a.values[0].fingerprint)
|
||||
self.assertEqual('1 2 00479b27', a.values[0].rdata_text)
|
||||
|
||||
def test_sshfp_value(self):
|
||||
a = SshfpValue(
|
||||
{'algorithm': 0, 'fingerprint_type': 0, 'fingerprint': 'abcd'}
|
||||
)
|
||||
b = SshfpValue(
|
||||
{'algorithm': 1, 'fingerprint_type': 0, 'fingerprint': 'abcd'}
|
||||
)
|
||||
c = SshfpValue(
|
||||
{'algorithm': 0, 'fingerprint_type': 1, 'fingerprint': 'abcd'}
|
||||
)
|
||||
d = SshfpValue(
|
||||
{'algorithm': 0, 'fingerprint_type': 0, 'fingerprint': 'bcde'}
|
||||
)
|
||||
|
||||
self.assertEqual(a, a)
|
||||
self.assertEqual(b, b)
|
||||
self.assertEqual(c, c)
|
||||
self.assertEqual(d, d)
|
||||
|
||||
self.assertNotEqual(a, b)
|
||||
self.assertNotEqual(a, c)
|
||||
self.assertNotEqual(a, d)
|
||||
self.assertNotEqual(b, a)
|
||||
self.assertNotEqual(b, c)
|
||||
self.assertNotEqual(b, d)
|
||||
self.assertNotEqual(c, a)
|
||||
self.assertNotEqual(c, b)
|
||||
self.assertNotEqual(c, d)
|
||||
self.assertNotEqual(d, a)
|
||||
self.assertNotEqual(d, b)
|
||||
self.assertNotEqual(d, c)
|
||||
|
||||
self.assertTrue(a < b)
|
||||
self.assertTrue(a < c)
|
||||
|
||||
self.assertTrue(b > a)
|
||||
self.assertTrue(b > c)
|
||||
|
||||
self.assertTrue(c > a)
|
||||
self.assertTrue(c < b)
|
||||
|
||||
self.assertTrue(a <= b)
|
||||
self.assertTrue(a <= c)
|
||||
self.assertTrue(a <= a)
|
||||
self.assertTrue(a >= a)
|
||||
|
||||
self.assertTrue(b >= a)
|
||||
self.assertTrue(b >= c)
|
||||
self.assertTrue(b >= b)
|
||||
self.assertTrue(b <= b)
|
||||
|
||||
self.assertTrue(c >= a)
|
||||
self.assertTrue(c <= b)
|
||||
self.assertTrue(c >= c)
|
||||
self.assertTrue(c <= c)
|
||||
|
||||
# Hash
|
||||
values = set()
|
||||
values.add(a)
|
||||
self.assertTrue(a in values)
|
||||
self.assertFalse(b in values)
|
||||
values.add(b)
|
||||
self.assertTrue(b in values)
|
||||
|
||||
def test_validation(self):
|
||||
# doesn't blow up
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'SSHFP',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'algorithm': 1,
|
||||
'fingerprint_type': 1,
|
||||
'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# missing algorithm
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'SSHFP',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'fingerprint_type': 1,
|
||||
'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing algorithm'], ctx.exception.reasons)
|
||||
|
||||
# invalid algorithm
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'SSHFP',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'algorithm': 'nope',
|
||||
'fingerprint_type': 2,
|
||||
'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['invalid algorithm "nope"'], ctx.exception.reasons)
|
||||
|
||||
# unrecognized algorithm
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'SSHFP',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'algorithm': 42,
|
||||
'fingerprint_type': 1,
|
||||
'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['unrecognized algorithm "42"'], ctx.exception.reasons)
|
||||
|
||||
# missing fingerprint_type
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'SSHFP',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'algorithm': 2,
|
||||
'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing fingerprint_type'], ctx.exception.reasons)
|
||||
|
||||
# invalid fingerprint_type
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'SSHFP',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'algorithm': 3,
|
||||
'fingerprint_type': 'yeeah',
|
||||
'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['invalid fingerprint_type "yeeah"'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# unrecognized fingerprint_type
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'SSHFP',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'algorithm': 1,
|
||||
'fingerprint_type': 42,
|
||||
'fingerprint': 'bf6b6825d2977c511a475bbefb88aad54a92ac73',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['unrecognized fingerprint_type "42"'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# missing fingerprint
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'SSHFP',
|
||||
'ttl': 600,
|
||||
'value': {'algorithm': 1, 'fingerprint_type': 1},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing fingerprint'], ctx.exception.reasons)
|
||||
@@ -0,0 +1,31 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record.alias import AliasRecord
|
||||
from octodns.record.target import _TargetValue
|
||||
from octodns.zone import Zone
|
||||
|
||||
|
||||
class TestRecordTarget(TestCase):
|
||||
def test_target_rdata_text(self):
|
||||
|
||||
# anything goes, we're a noop
|
||||
for s in (
|
||||
None,
|
||||
'',
|
||||
'word',
|
||||
42,
|
||||
42.43,
|
||||
'1.2.3',
|
||||
'some.words.that.here',
|
||||
'1.2.word.4',
|
||||
'1.2.3.4',
|
||||
):
|
||||
self.assertEqual(s, _TargetValue.parse_rdata_text(s))
|
||||
|
||||
zone = Zone('unit.tests.', [])
|
||||
a = AliasRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'})
|
||||
self.assertEqual('some.target.', a.value.rdata_text)
|
||||
@@ -0,0 +1,421 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.record.tlsa import TlsaRecord, TlsaValue
|
||||
from octodns.record.exception import ValidationError
|
||||
from octodns.record.rr import RrParseError
|
||||
from octodns.zone import Zone
|
||||
|
||||
from helpers import SimpleProvider
|
||||
|
||||
|
||||
class TestRecordTlsa(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
|
||||
def test_tlsa(self):
|
||||
a_values = [
|
||||
TlsaValue(
|
||||
{
|
||||
'certificate_usage': 1,
|
||||
'selector': 1,
|
||||
'matching_type': 1,
|
||||
'certificate_association_data': 'ABABABABABABABABAB',
|
||||
}
|
||||
),
|
||||
TlsaValue(
|
||||
{
|
||||
'certificate_usage': 2,
|
||||
'selector': 0,
|
||||
'matching_type': 2,
|
||||
'certificate_association_data': 'ABABABABABABABABAC',
|
||||
}
|
||||
),
|
||||
]
|
||||
a_data = {'ttl': 30, 'values': a_values}
|
||||
a = TlsaRecord(self.zone, 'a', a_data)
|
||||
self.assertEqual('a.unit.tests.', a.fqdn)
|
||||
self.assertEqual('a', a.name)
|
||||
self.assertEqual(30, a.ttl)
|
||||
self.assertEqual(
|
||||
a_values[0]['certificate_usage'], a.values[0].certificate_usage
|
||||
)
|
||||
self.assertEqual(a_values[0]['selector'], a.values[0].selector)
|
||||
self.assertEqual(
|
||||
a_values[0]['matching_type'], a.values[0].matching_type
|
||||
)
|
||||
self.assertEqual(
|
||||
a_values[0]['certificate_association_data'],
|
||||
a.values[0].certificate_association_data,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
a_values[1]['certificate_usage'], a.values[1].certificate_usage
|
||||
)
|
||||
self.assertEqual(a_values[1]['selector'], a.values[1].selector)
|
||||
self.assertEqual(
|
||||
a_values[1]['matching_type'], a.values[1].matching_type
|
||||
)
|
||||
self.assertEqual(
|
||||
a_values[1]['certificate_association_data'],
|
||||
a.values[1].certificate_association_data,
|
||||
)
|
||||
self.assertEqual(a_data, a.data)
|
||||
|
||||
b_value = TlsaValue(
|
||||
{
|
||||
'certificate_usage': 0,
|
||||
'selector': 0,
|
||||
'matching_type': 0,
|
||||
'certificate_association_data': 'AAAAAAAAAAAAAAA',
|
||||
}
|
||||
)
|
||||
b_data = {'ttl': 30, 'value': b_value}
|
||||
b = TlsaRecord(self.zone, 'b', b_data)
|
||||
self.assertEqual(
|
||||
b_value['certificate_usage'], b.values[0].certificate_usage
|
||||
)
|
||||
self.assertEqual(b_value['selector'], b.values[0].selector)
|
||||
self.assertEqual(b_value['matching_type'], b.values[0].matching_type)
|
||||
self.assertEqual(
|
||||
b_value['certificate_association_data'],
|
||||
b.values[0].certificate_association_data,
|
||||
)
|
||||
self.assertEqual(b_data, b.data)
|
||||
|
||||
target = SimpleProvider()
|
||||
# No changes with self
|
||||
self.assertFalse(a.changes(a, target))
|
||||
# Diff in certificate_usage causes change
|
||||
other = TlsaRecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
|
||||
other.values[0].certificate_usage = 0
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
# Diff in selector causes change
|
||||
other = TlsaRecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
|
||||
other.values[0].selector = 0
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
# Diff in matching_type causes change
|
||||
other = TlsaRecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
|
||||
other.values[0].matching_type = 0
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
# Diff in certificate_association_data causes change
|
||||
other = TlsaRecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
|
||||
other.values[0].certificate_association_data = 'AAAAAAAAAAAAA'
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
|
||||
# __repr__ doesn't blow up
|
||||
a.__repr__()
|
||||
|
||||
def test_tsla_value_rdata_text(self):
|
||||
|
||||
# empty string won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
TlsaValue.parse_rdata_text('')
|
||||
|
||||
# single word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
TlsaValue.parse_rdata_text('nope')
|
||||
|
||||
# 2nd word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
TlsaValue.parse_rdata_text('1 2')
|
||||
|
||||
# 3rd word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
TlsaValue.parse_rdata_text('1 2 3')
|
||||
|
||||
# 5th word won't parse
|
||||
with self.assertRaises(RrParseError):
|
||||
TlsaValue.parse_rdata_text('1 2 3 abcd another')
|
||||
|
||||
# non-ints
|
||||
self.assertEqual(
|
||||
{
|
||||
'certificate_usage': 'one',
|
||||
'selector': 'two',
|
||||
'matching_type': 'three',
|
||||
'certificate_association_data': 'abcd',
|
||||
},
|
||||
TlsaValue.parse_rdata_text('one two three abcd'),
|
||||
)
|
||||
|
||||
# valid
|
||||
self.assertEqual(
|
||||
{
|
||||
'certificate_usage': 1,
|
||||
'selector': 2,
|
||||
'matching_type': 3,
|
||||
'certificate_association_data': 'abcd',
|
||||
},
|
||||
TlsaValue.parse_rdata_text('1 2 3 abcd'),
|
||||
)
|
||||
|
||||
zone = Zone('unit.tests.', [])
|
||||
a = TlsaRecord(
|
||||
zone,
|
||||
'tlsa',
|
||||
{
|
||||
'ttl': 32,
|
||||
'value': {
|
||||
'certificate_usage': 2,
|
||||
'selector': 1,
|
||||
'matching_type': 0,
|
||||
'certificate_association_data': 'abcd',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(2, a.values[0].certificate_usage)
|
||||
self.assertEqual(1, a.values[0].selector)
|
||||
self.assertEqual(0, a.values[0].matching_type)
|
||||
self.assertEqual('abcd', a.values[0].certificate_association_data)
|
||||
self.assertEqual('2 1 0 abcd', a.values[0].rdata_text)
|
||||
|
||||
def test_validation(self):
|
||||
# doesn't blow up
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'TLSA',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'certificate_usage': 0,
|
||||
'selector': 0,
|
||||
'matching_type': 0,
|
||||
'certificate_association_data': 'AAAAAAAAAAAAA',
|
||||
},
|
||||
},
|
||||
)
|
||||
# Multi value, second missing certificate usage
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'TLSA',
|
||||
'ttl': 600,
|
||||
'values': [
|
||||
{
|
||||
'certificate_usage': 0,
|
||||
'selector': 0,
|
||||
'matching_type': 0,
|
||||
'certificate_association_data': 'AAAAAAAAAAAAA',
|
||||
},
|
||||
{
|
||||
'selector': 0,
|
||||
'matching_type': 0,
|
||||
'certificate_association_data': 'AAAAAAAAAAAAA',
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['missing certificate_usage'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# missing certificate_association_data
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'TLSA',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'certificate_usage': 0,
|
||||
'selector': 0,
|
||||
'matching_type': 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['missing certificate_association_data'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# missing certificate_usage
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'TLSA',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'selector': 0,
|
||||
'matching_type': 0,
|
||||
'certificate_association_data': 'AAAAAAAAAAAAA',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['missing certificate_usage'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# False certificate_usage
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'TLSA',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'certificate_usage': 4,
|
||||
'selector': 0,
|
||||
'matching_type': 0,
|
||||
'certificate_association_data': 'AAAAAAAAAAAAA',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
'invalid certificate_usage "{value["certificate_usage"]}"',
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
|
||||
# Invalid certificate_usage
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'TLSA',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'certificate_usage': 'XYZ',
|
||||
'selector': 0,
|
||||
'matching_type': 0,
|
||||
'certificate_association_data': 'AAAAAAAAAAAAA',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
'invalid certificate_usage "{value["certificate_usage"]}"',
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
|
||||
# missing selector
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'TLSA',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'certificate_usage': 0,
|
||||
'matching_type': 0,
|
||||
'certificate_association_data': 'AAAAAAAAAAAAA',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing selector'], ctx.exception.reasons)
|
||||
|
||||
# False selector
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'TLSA',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'certificate_usage': 0,
|
||||
'selector': 4,
|
||||
'matching_type': 0,
|
||||
'certificate_association_data': 'AAAAAAAAAAAAA',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
'invalid selector "{value["selector"]}"', ctx.exception.reasons
|
||||
)
|
||||
|
||||
# Invalid selector
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'TLSA',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'certificate_usage': 0,
|
||||
'selector': 'XYZ',
|
||||
'matching_type': 0,
|
||||
'certificate_association_data': 'AAAAAAAAAAAAA',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
'invalid selector "{value["selector"]}"', ctx.exception.reasons
|
||||
)
|
||||
|
||||
# missing matching_type
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'TLSA',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'certificate_usage': 0,
|
||||
'selector': 0,
|
||||
'certificate_association_data': 'AAAAAAAAAAAAA',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing matching_type'], ctx.exception.reasons)
|
||||
|
||||
# False matching_type
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'TLSA',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'certificate_usage': 0,
|
||||
'selector': 1,
|
||||
'matching_type': 3,
|
||||
'certificate_association_data': 'AAAAAAAAAAAAA',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
'invalid matching_type "{value["matching_type"]}"',
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
|
||||
# Invalid matching_type
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'TLSA',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'certificate_usage': 0,
|
||||
'selector': 1,
|
||||
'matching_type': 'XYZ',
|
||||
'certificate_association_data': 'AAAAAAAAAAAAA',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
'invalid matching_type "{value["matching_type"]}"',
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.record.txt import TxtRecord
|
||||
from octodns.record.exception import ValidationError
|
||||
from octodns.zone import Zone
|
||||
|
||||
|
||||
class TestRecordTxt(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
|
||||
def assertMultipleValues(self, _type, a_values, b_value):
|
||||
a_data = {'ttl': 30, 'values': a_values}
|
||||
a = _type(self.zone, 'a', a_data)
|
||||
self.assertEqual('a', a.name)
|
||||
self.assertEqual('a.unit.tests.', a.fqdn)
|
||||
self.assertEqual(30, a.ttl)
|
||||
self.assertEqual(a_values, a.values)
|
||||
self.assertEqual(a_data, a.data)
|
||||
|
||||
b_data = {'ttl': 30, 'value': b_value}
|
||||
b = _type(self.zone, 'b', b_data)
|
||||
self.assertEqual([b_value], b.values)
|
||||
self.assertEqual(b_data, b.data)
|
||||
|
||||
def test_txt(self):
|
||||
a_values = ['a one', 'a two']
|
||||
b_value = 'b other'
|
||||
self.assertMultipleValues(TxtRecord, a_values, b_value)
|
||||
|
||||
def test_validation(self):
|
||||
# doesn't blow up (name & zone here don't make any sense, but not
|
||||
# important)
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'TXT',
|
||||
'ttl': 600,
|
||||
'values': [
|
||||
'hello world',
|
||||
'this has some\\; semi-colons\\; in it',
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
# missing value
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(self.zone, '', {'type': 'TXT', 'ttl': 600})
|
||||
self.assertEqual(['missing value(s)'], ctx.exception.reasons)
|
||||
|
||||
# missing escapes
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'TXT',
|
||||
'ttl': 600,
|
||||
'value': 'this has some; semi-colons\\; in it',
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['unescaped ; in "this has some; semi-colons\\; in it"'],
|
||||
ctx.exception.reasons,
|
||||
)
|
||||
|
||||
def test_long_value_chunking(self):
|
||||
expected = (
|
||||
'"Lorem ipsum dolor sit amet, consectetur adipiscing '
|
||||
'elit, sed do eiusmod tempor incididunt ut labore et dolore '
|
||||
'magna aliqua. Ut enim ad minim veniam, quis nostrud '
|
||||
'exercitation ullamco laboris nisi ut aliquip ex ea commodo '
|
||||
'consequat. Duis aute irure dolor i" "n reprehenderit in '
|
||||
'voluptate velit esse cillum dolore eu fugiat nulla pariatur. '
|
||||
'Excepteur sint occaecat cupidatat non proident, sunt in culpa '
|
||||
'qui officia deserunt mollit anim id est laborum."'
|
||||
)
|
||||
|
||||
long_value = (
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing '
|
||||
'elit, sed do eiusmod tempor incididunt ut labore et dolore '
|
||||
'magna aliqua. Ut enim ad minim veniam, quis nostrud '
|
||||
'exercitation ullamco laboris nisi ut aliquip ex ea commodo '
|
||||
'consequat. Duis aute irure dolor in reprehenderit in '
|
||||
'voluptate velit esse cillum dolore eu fugiat nulla '
|
||||
'pariatur. Excepteur sint occaecat cupidatat non proident, '
|
||||
'sunt in culpa qui officia deserunt mollit anim id est '
|
||||
'laborum.'
|
||||
)
|
||||
# Single string
|
||||
single = Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'TXT',
|
||||
'ttl': 600,
|
||||
'values': [
|
||||
'hello world',
|
||||
long_value,
|
||||
'this has some\\; semi-colons\\; in it',
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertEqual(3, len(single.values))
|
||||
self.assertEqual(3, len(single.chunked_values))
|
||||
# Note we are checking that this normalizes the chunking, not that we
|
||||
# get out what we put in.
|
||||
self.assertEqual(expected, single.chunked_values[0])
|
||||
|
||||
long_split_value = (
|
||||
'"Lorem ipsum dolor sit amet, consectetur '
|
||||
'adipiscing elit, sed do eiusmod tempor incididunt ut '
|
||||
'labore et dolore magna aliqua. Ut enim ad minim veniam, '
|
||||
'quis nostrud exercitation ullamco laboris nisi ut aliquip '
|
||||
'ex" " ea commodo consequat. Duis aute irure dolor in '
|
||||
'reprehenderit in voluptate velit esse cillum dolore eu '
|
||||
'fugiat nulla pariatur. Excepteur sint occaecat cupidatat '
|
||||
'non proident, sunt in culpa qui officia deserunt mollit '
|
||||
'anim id est laborum."'
|
||||
)
|
||||
# Chunked
|
||||
chunked = Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'TXT',
|
||||
'ttl': 600,
|
||||
'values': [
|
||||
'"hello world"',
|
||||
long_split_value,
|
||||
'"this has some\\; semi-colons\\; in it"',
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertEqual(expected, chunked.chunked_values[0])
|
||||
# should be single values, no quoting
|
||||
self.assertEqual(single.values, chunked.values)
|
||||
# should be chunked values, with quoting
|
||||
self.assertEqual(single.chunked_values, chunked.chunked_values)
|
||||
@@ -0,0 +1,391 @@
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
from octodns.record import Record
|
||||
from octodns.record.urlfwd import UrlfwdRecord, UrlfwdValue
|
||||
from octodns.record.exception import ValidationError
|
||||
from octodns.zone import Zone
|
||||
|
||||
from helpers import SimpleProvider
|
||||
|
||||
|
||||
class TestRecordUrlfwd(TestCase):
|
||||
zone = Zone('unit.tests.', [])
|
||||
|
||||
def test_urlfwd(self):
|
||||
a_values = [
|
||||
UrlfwdValue(
|
||||
{
|
||||
'path': '/',
|
||||
'target': 'http://foo',
|
||||
'code': 301,
|
||||
'masking': 2,
|
||||
'query': 0,
|
||||
}
|
||||
),
|
||||
UrlfwdValue(
|
||||
{
|
||||
'path': '/target',
|
||||
'target': 'http://target',
|
||||
'code': 302,
|
||||
'masking': 2,
|
||||
'query': 0,
|
||||
}
|
||||
),
|
||||
]
|
||||
a_data = {'ttl': 30, 'values': a_values}
|
||||
a = UrlfwdRecord(self.zone, 'a', a_data)
|
||||
self.assertEqual('a', a.name)
|
||||
self.assertEqual('a.unit.tests.', a.fqdn)
|
||||
self.assertEqual(30, a.ttl)
|
||||
self.assertEqual(a_values[0]['path'], a.values[0].path)
|
||||
self.assertEqual(a_values[0]['target'], a.values[0].target)
|
||||
self.assertEqual(a_values[0]['code'], a.values[0].code)
|
||||
self.assertEqual(a_values[0]['masking'], a.values[0].masking)
|
||||
self.assertEqual(a_values[0]['query'], a.values[0].query)
|
||||
self.assertEqual(a_values[1]['path'], a.values[1].path)
|
||||
self.assertEqual(a_values[1]['target'], a.values[1].target)
|
||||
self.assertEqual(a_values[1]['code'], a.values[1].code)
|
||||
self.assertEqual(a_values[1]['masking'], a.values[1].masking)
|
||||
self.assertEqual(a_values[1]['query'], a.values[1].query)
|
||||
self.assertEqual(a_data, a.data)
|
||||
|
||||
b_value = UrlfwdValue(
|
||||
{
|
||||
'path': '/',
|
||||
'target': 'http://location',
|
||||
'code': 301,
|
||||
'masking': 2,
|
||||
'query': 0,
|
||||
}
|
||||
)
|
||||
b_data = {'ttl': 30, 'value': b_value}
|
||||
b = UrlfwdRecord(self.zone, 'b', b_data)
|
||||
self.assertEqual(b_value['path'], b.values[0].path)
|
||||
self.assertEqual(b_value['target'], b.values[0].target)
|
||||
self.assertEqual(b_value['code'], b.values[0].code)
|
||||
self.assertEqual(b_value['masking'], b.values[0].masking)
|
||||
self.assertEqual(b_value['query'], b.values[0].query)
|
||||
self.assertEqual(b_data, b.data)
|
||||
|
||||
target = SimpleProvider()
|
||||
# No changes with self
|
||||
self.assertFalse(a.changes(a, target))
|
||||
# Diff in path causes change
|
||||
other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
|
||||
other.values[0].path = '/change'
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
# Diff in target causes change
|
||||
other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
|
||||
other.values[0].target = 'http://target'
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
# Diff in code causes change
|
||||
other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
|
||||
other.values[0].code = 302
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
# Diff in masking causes change
|
||||
other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
|
||||
other.values[0].masking = 0
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
# Diff in query causes change
|
||||
other = UrlfwdRecord(self.zone, 'a', {'ttl': 30, 'values': a_values})
|
||||
other.values[0].query = 1
|
||||
change = a.changes(other, target)
|
||||
self.assertEqual(change.existing, a)
|
||||
self.assertEqual(change.new, other)
|
||||
|
||||
# hash
|
||||
v = UrlfwdValue(
|
||||
{
|
||||
'path': '/',
|
||||
'target': 'http://place',
|
||||
'code': 301,
|
||||
'masking': 2,
|
||||
'query': 0,
|
||||
}
|
||||
)
|
||||
o = UrlfwdValue(
|
||||
{
|
||||
'path': '/location',
|
||||
'target': 'http://redirect',
|
||||
'code': 302,
|
||||
'masking': 2,
|
||||
'query': 0,
|
||||
}
|
||||
)
|
||||
values = set()
|
||||
values.add(v)
|
||||
self.assertTrue(v in values)
|
||||
self.assertFalse(o in values)
|
||||
values.add(o)
|
||||
self.assertTrue(o in values)
|
||||
|
||||
# __repr__ doesn't blow up
|
||||
a.__repr__()
|
||||
|
||||
def test_validation(self):
|
||||
# doesn't blow up
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'URLFWD',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'path': '/',
|
||||
'target': 'http://foo',
|
||||
'code': 301,
|
||||
'masking': 2,
|
||||
'query': 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'URLFWD',
|
||||
'ttl': 600,
|
||||
'values': [
|
||||
{
|
||||
'path': '/',
|
||||
'target': 'http://foo',
|
||||
'code': 301,
|
||||
'masking': 2,
|
||||
'query': 0,
|
||||
},
|
||||
{
|
||||
'path': '/target',
|
||||
'target': 'http://target',
|
||||
'code': 302,
|
||||
'masking': 2,
|
||||
'query': 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
# missing path
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'URLFWD',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'target': 'http://foo',
|
||||
'code': 301,
|
||||
'masking': 2,
|
||||
'query': 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing path'], ctx.exception.reasons)
|
||||
|
||||
# missing target
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'URLFWD',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'path': '/',
|
||||
'code': 301,
|
||||
'masking': 2,
|
||||
'query': 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing target'], ctx.exception.reasons)
|
||||
|
||||
# missing code
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'URLFWD',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'path': '/',
|
||||
'target': 'http://foo',
|
||||
'masking': 2,
|
||||
'query': 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing code'], ctx.exception.reasons)
|
||||
|
||||
# invalid code
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'URLFWD',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'path': '/',
|
||||
'target': 'http://foo',
|
||||
'code': 'nope',
|
||||
'masking': 2,
|
||||
'query': 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['invalid return code "nope"'], ctx.exception.reasons)
|
||||
|
||||
# unrecognized code
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'URLFWD',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'path': '/',
|
||||
'target': 'http://foo',
|
||||
'code': 3,
|
||||
'masking': 2,
|
||||
'query': 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['unrecognized return code "3"'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# missing masking
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'URLFWD',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'path': '/',
|
||||
'target': 'http://foo',
|
||||
'code': 301,
|
||||
'query': 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing masking'], ctx.exception.reasons)
|
||||
|
||||
# invalid masking
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'URLFWD',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'path': '/',
|
||||
'target': 'http://foo',
|
||||
'code': 301,
|
||||
'masking': 'nope',
|
||||
'query': 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['invalid masking setting "nope"'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# unrecognized masking
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'URLFWD',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'path': '/',
|
||||
'target': 'http://foo',
|
||||
'code': 301,
|
||||
'masking': 3,
|
||||
'query': 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['unrecognized masking setting "3"'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# missing query
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'URLFWD',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'path': '/',
|
||||
'target': 'http://foo',
|
||||
'code': 301,
|
||||
'masking': 2,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(['missing query'], ctx.exception.reasons)
|
||||
|
||||
# invalid query
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'URLFWD',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'path': '/',
|
||||
'target': 'http://foo',
|
||||
'code': 301,
|
||||
'masking': 2,
|
||||
'query': 'nope',
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['invalid query setting "nope"'], ctx.exception.reasons
|
||||
)
|
||||
|
||||
# unrecognized query
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
Record.new(
|
||||
self.zone,
|
||||
'',
|
||||
{
|
||||
'type': 'URLFWD',
|
||||
'ttl': 600,
|
||||
'value': {
|
||||
'path': '/',
|
||||
'target': 'http://foo',
|
||||
'code': 301,
|
||||
'masking': 2,
|
||||
'query': 3,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
['unrecognized query setting "3"'], ctx.exception.reasons
|
||||
)
|
||||
Reference in New Issue
Block a user