From fd63150cac52ba28b305cfeaa4f0b6f2419c2acd Mon Sep 17 00:00:00 2001 From: Rhosyn Celyn Date: Mon, 13 May 2019 16:51:38 +0100 Subject: [PATCH] Added tests, clean up and small modifications --- octodns/provider/mythicbeasts.py | 274 ++++++++------- tests/fixtures/mythicbeasts-list.txt | 25 ++ tests/test_octodns_provider_mythicbeasts.py | 356 ++++++++++++++++++++ 3 files changed, 517 insertions(+), 138 deletions(-) create mode 100644 tests/fixtures/mythicbeasts-list.txt create mode 100644 tests/test_octodns_provider_mythicbeasts.py diff --git a/octodns/provider/mythicbeasts.py b/octodns/provider/mythicbeasts.py index dd11573..ca03b37 100644 --- a/octodns/provider/mythicbeasts.py +++ b/octodns/provider/mythicbeasts.py @@ -7,7 +7,7 @@ from __future__ import absolute_import, division, print_function, \ import re -from requests import HTTPError, Session +from requests import Session from logging import getLogger from ..record import Record @@ -18,8 +18,8 @@ def add_trailing_dot(value): ''' Add trailing dots to values ''' - assert value - assert value[-1] != '.' + assert value, 'Missing value' + assert value[-1] != '.', 'Value already has trailing dot' return value + '.' @@ -27,8 +27,8 @@ def remove_trailing_dot(value): ''' Remove trailing dots from values ''' - assert value - assert value[-1] == '.' + assert value, 'Missing value' + assert value[-1] == '.', 'Value already missing trailing dot' return value[:-1] @@ -44,13 +44,15 @@ class MythicBeastsProvider(BaseProvider): SUPPORTS_GEO = False SUPPORTS_DYNAMIC = False - SUPPORTS = set(('A', 'AAAA', 'CNAME', 'MX', 'NS', + SUPPORTS = set(('A', 'AAAA', 'ALIAS', 'CNAME', 'MX', 'NS', 'SRV', 'SSHFP', 'CAA', 'TXT')) BASE = 'https://dnsapi.mythic-beasts.com/' - TIMEOUT = 15 def __init__(self, identifier, passwords, *args, **kwargs): self.log = getLogger('MythicBeastsProvider[{}]'.format(identifier)) + + assert isinstance(passwords, dict), 'Missing passwords' + self.log.debug( '__init__: id=%s, registered zones; %s', identifier, @@ -62,23 +64,28 @@ class MythicBeastsProvider(BaseProvider): self._sess = sess def _request(self, method, path, data=None): - self.log.debug('_request: method=%s, path=%s', method, path) + self.log.debug('_request: method=%s, path=%s data=%s', + method, path, data) - url = self.BASE - resp = self._sess.request(method, url, data=data, timeout=self.TIMEOUT) + resp = self._sess.request(method, path, data=data) self.log.debug( '_request: status=%d data=%s', resp.status_code, - resp.text) - if resp.status_code != 200: - self.log.info('request failed: %s, response %s', data, resp.text) - resp.raise_for_status() + resp.text[:20]) + + if resp.status_code == 401: + raise Exception('Mythic Beasts unauthorized for domain: {}' + .format(data['domain'])) + resp.raise_for_status() return resp def _post(self, data=None): return self._request('POST', self.BASE, data=data) def records(self, zone): + assert zone in self._passwords, 'Missing password for domain: {}' \ + .format(remove_trailing_dot(zone)) + return self._post({ 'domain': remove_trailing_dot(zone), 'password': self._passwords[zone], @@ -113,16 +120,17 @@ class MythicBeastsProvider(BaseProvider): [raw_values['value'] for raw_values in data['raw_values']]: match = re.match('^([0-9]+)\\s+(\\S+)$', raw_value, re.IGNORECASE) - if match is not None: - exchange = match.group(2) + assert match is not None, 'Unable to parse MX data' - if not exchange.endswith('.'): - exchange = '{}.{}'.format(exchange, data['zone']) + exchange = match.group(2) - values.append({ - 'preference': match.group(1), - 'exchange': exchange, - }) + if not exchange.endswith('.'): + exchange = '{}.{}'.format(exchange, data['zone']) + + values.append({ + 'preference': match.group(1), + 'exchange': exchange, + }) return { 'type': _type, @@ -155,54 +163,61 @@ class MythicBeastsProvider(BaseProvider): @staticmethod def _data_for_SRV(_type, data): - ttl = data['raw_values'][0]['ttl'] - raw_value = data['raw_values'][0]['value'] + ttl = max([raw_values['ttl'] for raw_values in data['raw_values']]) + values = [] - match = re.match( - '^([0-9]+)\\s+([0-9]+)\\s+([0-9]+)\\s+(\\S+)$', - raw_value, - re.IGNORECASE) + for raw_value in \ + [raw_values['value'] for raw_values in data['raw_values']]: + + match = re.match( + '^([0-9]+)\\s+([0-9]+)\\s+([0-9]+)\\s+(\\S+)$', + raw_value, + re.IGNORECASE) + + assert match is not None, 'Unable to parse SRV data' - if match is not None: target = match.group(4) if not target.endswith('.'): target = '{}.{}'.format(target, data['zone']) - value = { + values.append({ 'priority': match.group(1), 'weight': match.group(2), 'port': match.group(3), 'target': target, - } + }) - return MythicBeastsProvider._data_for_single( - 'SRV', - {'raw_values': [ - {'value': value, 'ttl': ttl} - ]}) + return { + 'type': _type, + 'values': values, + 'ttl': ttl, + } @staticmethod def _data_for_SSHFP(_type, data): - ttl = data['raw_values'][0]['ttl'] - raw_value = data['raw_values'][0]['value'] + ttl = max([raw_values['ttl'] for raw_values in data['raw_values']]) + values = [] - match = re.match( - '^([0-9]+)\\s+([0-9]+)\\s+(\\S+)$', - raw_value, - re.IGNORECASE) + for raw_value in \ + [raw_values['value'] for raw_values in data['raw_values']]: + match = re.match( + '^([0-9]+)\\s+([0-9]+)\\s+(\\S+)$', + raw_value, + re.IGNORECASE) - if match is not None: - value = { + assert match is not None, 'Unable to parse SSHFP data' + + values.append({ 'algorithm': match.group(1), 'fingerprint_type': match.group(2), 'fingerprint': match.group(3), - } + }) - return MythicBeastsProvider._data_for_single( - 'SSHFP', - {'raw_values': [ - {'value': value, 'ttl': ttl} - ]}) + return { + 'type': _type, + 'values': values, + 'ttl': ttl, + } @staticmethod def _data_for_CAA(_type, data): @@ -214,17 +229,17 @@ class MythicBeastsProvider(BaseProvider): raw_value, re.IGNORECASE) - if match is not None: - value = { - 'flags': match.group(1), - 'tag': match.group(2), - 'value': match.group(3), - } - return MythicBeastsProvider._data_for_single( - 'CAA', - {'raw_values': [ - {'value': value, 'ttl': ttl} - ]}) + assert match is not None, 'Unable to parse CAA data' + + value = { + 'flags': match.group(1), + 'tag': match.group(2), + 'value': match.group(3), + } + + return MythicBeastsProvider._data_for_single( + 'CAA', + {'raw_values': [{'value': value, 'ttl': ttl}]}) _data_for_NS = _data_for_multiple _data_for_TXT = _data_for_multiple @@ -235,84 +250,69 @@ class MythicBeastsProvider(BaseProvider): self.log.debug('populate: name=%s, target=%s, lenient=%s', zone.name, target, lenient) - resp = None - try: - resp = self.records(zone.name) - except HTTPError as err: - if err.response.status_code == 401: - # Nicer error message for auth problems - raise Exception( - 'Mythic Beasts authentication problem with {}'.format( - zone.name)) - else: - # just re-throw - raise + resp = self.records(zone.name) before = len(zone.records) exists = False data = dict() - if resp: - exists = True - for line in resp.content.splitlines(): - match = re.match( - '^(\\S+)\\s+(\\S+)\\s+(\\S+)\\s+(.*)$', - line, - re.IGNORECASE) + exists = True + for line in resp.content.splitlines(): + match = re.match( + '^(\\S+)\\s+(\\d+)\\s+(\\S+)\\s+(.*)$', + line, + re.IGNORECASE) - if match is not None: - if match.group(1) == '@': - _name = '' - else: - _name = match.group(1) + if match is None: + self.log.debug('failed to match line: %s', line) + continue - _type = match.group(3) - _ttl = int(match.group(2)) - _value = match.group(4).strip() + if match.group(1) == '@': + _name = '' + else: + _name = match.group(1) - if _type == 'SOA': - continue + _type = match.group(3) + _ttl = int(match.group(2)) + _value = match.group(4).strip() - try: - if getattr(self, '_data_for_{}'.format(_type)) is not None: + if _type == 'TXT': + _value = _value.replace(';', '\\;') - if _type not in data: - data[_type] = dict() + if hasattr(self, '_data_for_{}'.format(_type)): - if _name not in data[_type]: - data[_type][_name] = { - 'raw_values': [{'value': _value, 'ttl': _ttl}], - 'name': _name, - 'zone': zone.name, - } + if _type not in data: + data[_type] = dict() - else: - data[_type][_name].get('raw_values').append( - {'value': _value, 'ttl': _ttl} - ) - except AttributeError: - self.log.debug('skipping {} as not supported', _type) - continue + if _name not in data[_type]: + data[_type][_name] = { + 'raw_values': [{'value': _value, 'ttl': _ttl}], + 'name': _name, + 'zone': zone.name, + } - for _type in data: - for _name in data[_type]: - data_for = getattr(self, '_data_for_{}'.format(_type)) - - self.log.debug( - 'record: %s,\t%s', - _type, - data[_type][_name]) - - record = Record.new( - zone, - _name, - data_for(_type, data[_type][_name]), - source=self + else: + data[_type][_name].get('raw_values').append( + {'value': _value, 'ttl': _ttl} ) - zone.add_record(record, lenient=lenient) + else: + self.log.debug('skipping %s as not supported', _type) + + for _type in data: + for _name in data[_type]: + data_for = getattr(self, '_data_for_{}'.format(_type)) + + record = Record.new( + zone, + _name, + data_for(_type, data[_type][_name]), + source=self + ) + zone.add_record(record, lenient=lenient) self.log.debug('populate: found %s records, exists=%s', len(zone.records) - before, exists) + return exists def _compile_commands(self, action, change): @@ -322,8 +322,7 @@ class MythicBeastsProvider(BaseProvider): if action == 'ADD': record = change.new - - elif action == 'DELETE': + else: record = change.existing hostname = remove_trailing_dot(record.fqdn) @@ -354,13 +353,14 @@ class MythicBeastsProvider(BaseProvider): )) elif _type == 'SRV': - data = values[0].data - commands.append('{} {} {} {} {}'.format( - base, - data['priority'], - data['weight'], - data['port'], - data['target'])) + for value in values: + data = value.data + commands.append('{} {} {} {} {}'.format( + base, + data['priority'], + data['weight'], + data['port'], + data['target'])) elif _type == 'MX': for value in values: @@ -371,13 +371,11 @@ class MythicBeastsProvider(BaseProvider): data['exchange'])) else: - try: - if getattr(self, '_data_for_{}'.format(_type)) is not None: - commands.append('{} {}'.format( - base, values[0])) - except AttributeError: - self.log.debug('skipping {} as not supported', _type) - pass + if hasattr(self, '_data_for_{}'.format(_type)): + commands.append('{} {}'.format( + base, values[0])) + else: + self.log.debug('skipping %s as not supported', _type) return commands diff --git a/tests/fixtures/mythicbeasts-list.txt b/tests/fixtures/mythicbeasts-list.txt new file mode 100644 index 0000000..ed4ea4c --- /dev/null +++ b/tests/fixtures/mythicbeasts-list.txt @@ -0,0 +1,25 @@ +@ 3600 NS 6.2.3.4. +@ 3600 NS 7.2.3.4. +@ 300 A 1.2.3.4 +@ 300 A 1.2.3.5 +@ 3600 SSHFP 1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73 +@ 3600 SSHFP 1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49 +@ 3600 CAA 0 issue ca.unit.tests +_srv._tcp 600 SRV 10 20 30 foo-1.unit.tests. +_srv._tcp 600 SRV 12 20 30 foo-2.unit.tests. +aaaa 600 AAAA 2601:644:500:e210:62f8:1dff:feb8:947a +cname 300 CNAME unit.tests. +excluded 300 CNAME unit.tests. +ignored 300 A 9.9.9.9 +included 3600 CNAME unit.tests. +mx 300 MX 10 smtp-4.unit.tests. +mx 300 MX 20 smtp-2.unit.tests. +mx 300 MX 30 smtp-3.unit.tests. +mx 300 MX 40 smtp-1.unit.tests. +sub 3600 NS 6.2.3.4. +sub 3600 NS 7.2.3.4. +txt 600 TXT "Bah bah black sheep" +txt 600 TXT "have you any wool." +txt 600 TXT "v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string+with+numb3rs" +www 300 A 2.2.3.6 +www.sub 300 A 2.2.3.6 diff --git a/tests/test_octodns_provider_mythicbeasts.py b/tests/test_octodns_provider_mythicbeasts.py new file mode 100644 index 0000000..a1ef5d5 --- /dev/null +++ b/tests/test_octodns_provider_mythicbeasts.py @@ -0,0 +1,356 @@ +# +# +# + +from __future__ import absolute_import, division, print_function, \ + unicode_literals + +from os.path import dirname, join + +from requests_mock import ANY, mock as requests_mock +from unittest import TestCase + +from octodns.provider.mythicbeasts import MythicBeastsProvider, \ + add_trailing_dot, remove_trailing_dot +from octodns.provider.yaml import YamlProvider +from octodns.zone import Zone +from octodns.record import Create, Update, Delete, Record + + +class TestMythicBeastsProvider(TestCase): + expected = Zone('unit.tests.', []) + source = YamlProvider('test_expected', join(dirname(__file__), 'config')) + source.populate(expected) + + # Dump anything we don't support from expected + for record in list(expected.records): + if record._type not in MythicBeastsProvider.SUPPORTS: + expected._remove_record(record) + + def test_trailing_dot(self): + with self.assertRaises(AssertionError) as err: + add_trailing_dot('unit.tests.') + self.assertEquals('Value already has trailing dot', + err.exception.message) + + with self.assertRaises(AssertionError) as err: + remove_trailing_dot('unit.tests') + self.assertEquals('Value already missing trailing dot', + err.exception.message) + + self.assertEquals(add_trailing_dot('unit.tests'), 'unit.tests.') + self.assertEquals(remove_trailing_dot('unit.tests.'), 'unit.tests') + + def test_data_for_single(self): + test_data = { + 'raw_values': [{'value': 'a:a::c', 'ttl': 0}], + 'zone': 'unit.tests.', + } + test_single = MythicBeastsProvider._data_for_single('', test_data) + self.assertTrue(isinstance(test_single, dict)) + self.assertEquals('a:a::c', test_single['value']) + + def test_data_for_multiple(self): + test_data = { + 'raw_values': [ + {'value': 'b:b::d', 'ttl': 60}, + {'value': 'a:a::c', 'ttl': 60}], + 'zone': 'unit.tests.', + } + test_multiple = MythicBeastsProvider._data_for_multiple('', test_data) + self.assertTrue(isinstance(test_multiple, dict)) + self.assertEquals(2, len(test_multiple['values'])) + + def test_data_for_MX(self): + test_data = { + 'raw_values': [ + {'value': '10 un.unit', 'ttl': 60}, + {'value': '20 dau.unit', 'ttl': 60}, + {'value': '30 tri.unit', 'ttl': 60}], + 'zone': 'unit.tests.', + } + test_MX = MythicBeastsProvider._data_for_MX('', test_data) + self.assertTrue(isinstance(test_MX, dict)) + self.assertEquals(3, len(test_MX['values'])) + + with self.assertRaises(AssertionError) as err: + test_MX = MythicBeastsProvider._data_for_MX( + '', + {'raw_values': [{'value': '', 'ttl': 0}]} + ) + self.assertEquals('Unable to parse MX data', + err.exception.message) + + def test_data_for_CNAME(self): + test_data = { + 'raw_values': [{'value': 'cname', 'ttl': 60}], + 'zone': 'unit.tests.', + } + test_cname = MythicBeastsProvider._data_for_CNAME('', test_data) + self.assertTrue(isinstance(test_cname, dict)) + self.assertEquals('cname.unit.tests.', test_cname['value']) + + def test_data_for_ANAME(self): + test_data = { + 'raw_values': [{'value': 'aname', 'ttl': 60}], + 'zone': 'unit.tests.', + } + test_aname = MythicBeastsProvider._data_for_ANAME('', test_data) + self.assertTrue(isinstance(test_aname, dict)) + self.assertEquals('aname', test_aname['value']) + + def test_data_for_SRV(self): + test_data = { + 'raw_values': [ + {'value': '10 20 30 un.srv.unit', 'ttl': 60}, + {'value': '20 30 40 dau.srv.unit', 'ttl': 60}, + {'value': '30 30 50 tri.srv.unit', 'ttl': 60}], + 'zone': 'unit.tests.', + } + test_SRV = MythicBeastsProvider._data_for_SRV('', test_data) + self.assertTrue(isinstance(test_SRV, dict)) + self.assertEquals(3, len(test_SRV['values'])) + + with self.assertRaises(AssertionError) as err: + test_SRV = MythicBeastsProvider._data_for_SRV( + '', + {'raw_values': [{'value': '', 'ttl': 0}]} + ) + self.assertEquals('Unable to parse SRV data', + err.exception.message) + + def test_data_for_SSHFP(self): + test_data = { + 'raw_values': [ + {'value': '1 1 0123456789abcdef', 'ttl': 60}, + {'value': '1 2 0123456789abcdef', 'ttl': 60}, + {'value': '2 3 0123456789abcdef', 'ttl': 60}], + 'zone': 'unit.tests.', + } + test_SSHFP = MythicBeastsProvider._data_for_SSHFP('', test_data) + self.assertTrue(isinstance(test_SSHFP, dict)) + self.assertEquals(3, len(test_SSHFP['values'])) + + with self.assertRaises(AssertionError) as err: + test_SSHFP = MythicBeastsProvider._data_for_SSHFP( + '', + {'raw_values': [{'value': '', 'ttl': 0}]} + ) + self.assertEquals('Unable to parse SSHFP data', + err.exception.message) + + def test_data_for_CAA(self): + test_data = { + 'raw_values': [{'value': '1 issue letsencrypt.org', 'ttl': 60}], + 'zone': 'unit.tests.', + } + test_CAA = MythicBeastsProvider._data_for_CAA('', test_data) + self.assertTrue(isinstance(test_CAA, dict)) + self.assertEquals(3, len(test_CAA['value'])) + + with self.assertRaises(AssertionError) as err: + test_CAA = MythicBeastsProvider._data_for_CAA( + '', + {'raw_values': [{'value': '', 'ttl': 0}]} + ) + self.assertEquals('Unable to parse CAA data', + err.exception.message) + + def test_alias_command_generation(self): + zone = Zone('unit.tests.', []) + zone.add_record(Record.new(zone, 'prawf', { + 'ttl': 60, + 'type': 'ALIAS', + 'value': 'alias.unit.tests.', + })) + with requests_mock() as mock: + mock.post(ANY, status_code=200, text='') + + provider = MythicBeastsProvider('test', { + 'unit.tests.': 'mypassword' + }) + + plan = provider.plan(zone) + change = plan.changes[0] + + command = provider._compile_commands('ADD', change) + self.assertEquals( + ['ADD prawf.unit.tests 60 ANAME alias.unit.tests.'], + command + ) + + def test_txt_command_generation(self): + zone = Zone('unit.tests.', []) + zone.add_record(Record.new(zone, 'prawf', { + 'ttl': 60, + 'type': 'TXT', + 'value': 'prawf prawf dyma prawf', + })) + with requests_mock() as mock: + mock.post(ANY, status_code=200, text='') + + provider = MythicBeastsProvider('test', { + 'unit.tests.': 'mypassword' + }) + + plan = provider.plan(zone) + change = plan.changes[0] + + command = provider._compile_commands('ADD', change) + self.assertEquals( + ['ADD prawf.unit.tests 60 TXT prawf prawf dyma prawf'], + command + ) + + def test_command_generation(self): + class FakeChangeRecord(object): + def __init__(self): + self.__fqdn = 'prawf.unit.tests.' + self._type = 'NOOP' + self.value = 'prawf' + self.ttl = 60 + + @property + def new(self): + return self + + @property + def existing(self): + return self + + @property + def fqdn(self): + return self.__fqdn + + with requests_mock() as mock: + mock.post(ANY, status_code=200, text='') + + provider = MythicBeastsProvider('test', { + 'unit.tests.': 'mypassword' + }) + record = FakeChangeRecord() + command = provider._compile_commands('ADD', record) + self.assertEquals([], command) + + def test_populate(self): + provider = None + + # Null passwords dict + with self.assertRaises(AssertionError) as err: + provider = MythicBeastsProvider('test', None) + self.assertEquals('Missing passwords', err.exception.message) + + # Missing password + with requests_mock() as mock: + mock.post(ANY, status_code=401, text='ERR Not authenticated') + + with self.assertRaises(AssertionError) as err: + provider = MythicBeastsProvider('test', dict()) + zone = Zone('unit.tests.', []) + provider.populate(zone) + self.assertEquals( + 'Missing password for domain: unit.tests', + err.exception.message) + + # Failed authentication + with requests_mock() as mock: + mock.post(ANY, status_code=401, text='ERR Not authenticated') + + with self.assertRaises(Exception) as err: + provider = MythicBeastsProvider('test', { + 'unit.tests.': 'mypassword' + }) + zone = Zone('unit.tests.', []) + provider.populate(zone) + self.assertEquals( + 'Mythic Beasts unauthorized for domain: unit.tests', + err.exception.message) + + # Check unmatched lines are ignored + test_data = 'This should not match' + with requests_mock() as mock: + mock.post(ANY, status_code=200, text=test_data) + + provider = MythicBeastsProvider('test', { + 'unit.tests.': 'mypassword' + }) + zone = Zone('unit.tests.', []) + provider.populate(zone) + self.assertEquals(0, len(zone.records)) + + # Check unsupported records are skipped + test_data = '@ 60 NOOP prawf\n@ 60 SPF prawf prawf prawf' + with requests_mock() as mock: + mock.post(ANY, status_code=200, text=test_data) + + provider = MythicBeastsProvider('test', { + 'unit.tests.': 'mypassword' + }) + zone = Zone('unit.tests.', []) + provider.populate(zone) + self.assertEquals(0, len(zone.records)) + + # Check no changes between what we support and what's parsed + # from the unit.tests. config YAML. Also make sure we see the same + # for both after we've thrown away records we don't support + with requests_mock() as mock: + with open('tests/fixtures/mythicbeasts-list.txt') as file_handle: + mock.post(ANY, status_code=200, text=file_handle.read()) + + provider = MythicBeastsProvider('test', { + 'unit.tests.': 'mypassword' + }) + zone = Zone('unit.tests.', []) + provider.populate(zone) + + self.assertEquals(15, len(zone.records)) + self.assertEquals(15, len(self.expected.records)) + changes = self.expected.changes(zone, provider) + self.assertEquals(0, len(changes)) + + def test_apply(self): + provider = MythicBeastsProvider('test', { + 'unit.tests.': 'mypassword' + }) + zone = Zone('unit.tests.', []) + + # Create blank zone + with requests_mock() as mock: + mock.post(ANY, status_code=200, text='') + provider.populate(zone) + + self.assertEquals(0, len(zone.records)) + + # Check deleting and adding/changing test record + existing = 'prawf 300 TXT prawf prawf prawf\ndileu 300 TXT dileu' + + with requests_mock() as mock: + mock.post(ANY, status_code=200, text=existing) + + # Mash up a new zone with records so a plan + # is generated with changes and applied. For some reason + # passing self.expected, or just changing each record's zone + # doesn't work. Nor does this without a single add_record after + wanted = Zone('unit.tests.', []) + for record in list(self.expected.records): + data = {'type': record._type} + data.update(record.data) + wanted.add_record(Record.new(wanted, record.name, data)) + + wanted.add_record(Record.new(wanted, 'prawf', { + 'ttl': 60, + 'type': 'TXT', + 'value': 'prawf yw e', + })) + + plan = provider.plan(wanted) + + # Octo ignores NS records (15-1) + self.assertEquals(1, len(filter(lambda u: isinstance(u, Update), + plan.changes))) + self.assertEquals(1, len(filter(lambda d: isinstance(d, Delete), + plan.changes))) + self.assertEquals(14, len(filter(lambda c: isinstance(c, Create), + plan.changes))) + self.assertEquals(16, provider.apply(plan)) + self.assertTrue(plan.exists)