def test_apply_unescapes_semicolons(self): desired = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(desired) # save the rrsets sent to the API so we can assert them later. patch_rrsets = [] def save_rrsets_callback(request, context): data = loads(request.body) patch_rrsets.extend(data['rrsets']) return '' provider = PowerDnsProvider('test', 'non.existant', 'api-key') with requests_mock() as mock: mock.get(ANY, status_code=200, text=EMPTY_TEXT) # post 201, is response to the create with data mock.patch(ANY, status_code=201, text=save_rrsets_callback) plan = provider.plan(desired) provider.apply(plan) txts = [c for c in patch_rrsets if c['type'] == 'TXT'] self.assertEquals( '"v=DKIM1;k=rsa;s=email;h=sha256;' 'p=A/kinda+of/long/string+with+numb3rs"', txts[0]['records'][2]['content'])
def test_unsorted(self): source = YamlProvider('test', join(dirname(__file__), 'config')) zone = Zone('unordered.', []) with self.assertRaises(ConstructorError): source.populate(zone)
def test_empty(self): source = YamlProvider('test', join(dirname(__file__), 'config')) zone = Zone('empty.', []) # without it we see everything source.populate(zone) self.assertEquals(0, len(zone.records))
def test_subzone_handling(self): source = YamlProvider('test', join(dirname(__file__), 'config')) # If we add `sub` as a sub-zone we'll reject `www.sub` zone = Zone('unit.tests.', ['sub']) with self.assertRaises(SubzoneRecordException) as ctx: source.populate(zone) self.assertEquals('Record www.sub.unit.tests. is under a managed ' 'subzone', text_type(ctx.exception))
def test_provider(self): config = join(dirname(__file__), 'config') override_config = join(dirname(__file__), 'config', 'override') base = YamlProvider('base', config, populate_should_replace=False) override = YamlProvider('test', override_config, populate_should_replace=True) zone = Zone('dynamic.tests.', []) # Load the base, should see the 5 records base.populate(zone) got = {r.name: r for r in zone.records} self.assertEquals(6, len(got)) # We get the "dynamic" A from the base config self.assertTrue('dynamic' in got['a'].data) # No added self.assertFalse('added' in got) # Load the overrides, should replace one and add 1 override.populate(zone) got = {r.name: r for r in zone.records} self.assertEquals(7, len(got)) # 'a' was replaced with a generic record self.assertEquals({ 'ttl': 3600, 'values': ['4.4.4.4', '5.5.5.5'] }, got['a'].data) # And we have the new one self.assertTrue('added' in got)
def test_small_change(self): provider = PowerDnsProvider('test', 'non.existent', 'api-key') expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) self.assertEquals(23, len(expected.records)) # A small change to a single record with requests_mock() as mock: mock.get(ANY, status_code=200, text=FULL_TEXT) mock.get('http://non.existent:8081/api/v1/servers/localhost', status_code=200, json={'version': '4.1.0'}) missing = Zone(expected.name, []) # Find and delete the SPF record for record in expected.records: if record._type != 'SPF': missing.add_record(record) def assert_delete_callback(request, context): self.assertEquals( { 'rrsets': [{ 'records': [{ 'content': '"v=spf1 ip4:192.168.0.1/16-all"', 'disabled': False }], 'changetype': 'DELETE', 'type': 'SPF', 'name': 'spf.unit.tests.', 'ttl': 600 }] }, loads(request.body)) return '' mock.patch(ANY, status_code=201, text=assert_delete_callback) plan = provider.plan(missing) self.assertEquals(1, len(plan.changes)) self.assertEquals(1, provider.apply(plan))
def test_unsorted(self): source = YamlProvider('test', join(dirname(__file__), 'config')) zone = Zone('unordered.', []) with self.assertRaises(ConstructorError): source.populate(zone) source = YamlProvider('test', join(dirname(__file__), 'config'), enforce_order=False) # no exception source.populate(zone) self.assertEqual(2, len(zone.records))
def octodns_test_zone(): '''Load the unit.tests zone config into an octodns.zone.Zone object.''' zone = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(zone) # Replace the unit test fixture's NS record with one of ours. remove_octodns_record(zone, '', 'NS') zone.add_record( Record.new( zone, '', { 'ttl': 3600, 'type': 'NS', 'values': [ 'dns1.p01.nsone.net.', 'dns2.p01.nsone.net.', 'dns3.p01.nsone.net.', 'dns4.p01.nsone.net.' ] })) return zone
def test_copy(self): # going to put some sentinal values in here to ensure, these aren't # valid, but we shouldn't hit any code that cares during this test source = YamlProvider( 'test', 42, default_ttl=43, enforce_order=44, populate_should_replace=45, supports_root_ns=46, ) copy = source.copy() self.assertEqual(source.directory, copy.directory) self.assertEqual(source.default_ttl, copy.default_ttl) self.assertEqual(source.enforce_order, copy.enforce_order) self.assertEqual(source.populate_should_replace, copy.populate_should_replace) self.assertEqual(source.supports_root_ns, copy.supports_root_ns) # same for split source = SplitYamlProvider( 'test', 42, extension=42.5, default_ttl=43, enforce_order=44, populate_should_replace=45, supports_root_ns=46, ) copy = source.copy() self.assertEqual(source.directory, copy.directory) self.assertEqual(source.extension, copy.extension) self.assertEqual(source.default_ttl, copy.default_ttl) self.assertEqual(source.enforce_order, copy.enforce_order) self.assertEqual(source.populate_should_replace, copy.populate_should_replace) self.assertEqual(source.supports_root_ns, copy.supports_root_ns)
class TestDigitalOceanProvider(TestCase): expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) # Our test suite differs a bit, add our NS and remove the simple one expected.add_record( Record.new( expected, 'under', { 'ttl': 3600, 'type': 'NS', 'values': [ 'ns1.unit.tests.', 'ns2.unit.tests.', ] })) for record in list(expected.records): if record.name == 'sub' and record._type == 'NS': expected._remove_record(record) break def test_populate(self): provider = DigitalOceanProvider('test', 'token') # Bad auth with requests_mock() as mock: mock.get(ANY, status_code=401, text='{"id":"unauthorized",' '"message":"Unable to authenticate you."}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('Unauthorized', ctx.exception.message) # General error with requests_mock() as mock: mock.get(ANY, status_code=502, text='Things caught fire') with self.assertRaises(HTTPError) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(502, ctx.exception.response.status_code) # Non-existant zone doesn't populate anything with requests_mock() as mock: mock.get(ANY, status_code=404, text='{"id":"not_found","message":"The resource you ' 'were accessing could not be found."}') zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(set(), zone.records) # No diffs == no changes with requests_mock() as mock: base = 'https://api.digitalocean.com/v2/domains/unit.tests/' \ 'records?page=' with open('tests/fixtures/digitalocean-page-1.json') as fh: mock.get('{}{}'.format(base, 1), text=fh.read()) with open('tests/fixtures/digitalocean-page-2.json') as fh: mock.get('{}{}'.format(base, 2), text=fh.read()) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(12, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEquals(0, len(changes)) # 2nd populate makes no network calls/all from cache again = Zone('unit.tests.', []) provider.populate(again) self.assertEquals(12, len(again.records)) # bust the cache del provider._zone_records[zone.name] def test_apply(self): provider = DigitalOceanProvider('test', 'token') resp = Mock() resp.json = Mock() provider._client._request = Mock(return_value=resp) domain_after_creation = { "domain_records": [{ "id": 11189874, "type": "NS", "name": "@", "data": "ns1.digitalocean.com", "priority": None, "port": None, "ttl": 3600, "weight": None, "flags": None, "tag": None }, { "id": 11189875, "type": "NS", "name": "@", "data": "ns2.digitalocean.com", "priority": None, "port": None, "ttl": 3600, "weight": None, "flags": None, "tag": None }, { "id": 11189876, "type": "NS", "name": "@", "data": "ns3.digitalocean.com", "priority": None, "port": None, "ttl": 3600, "weight": None, "flags": None, "tag": None }, { "id": 11189877, "type": "A", "name": "@", "data": "192.0.2.1", "priority": None, "port": None, "ttl": 3600, "weight": None, "flags": None, "tag": None }], "links": {}, "meta": { "total": 4 } } # non-existant domain, create everything resp.json.side_effect = [ DigitalOceanClientNotFound, # no zone in populate DigitalOceanClientNotFound, # no domain during apply domain_after_creation ] plan = provider.plan(self.expected) # No root NS, no ignored, no excluded, no unsupported n = len(self.expected.records) - 7 self.assertEquals(n, len(plan.changes)) self.assertEquals(n, provider.apply(plan)) self.assertFalse(plan.exists) provider._client._request.assert_has_calls([ # created the domain call('POST', '/domains', data={ 'ip_address': '192.0.2.1', 'name': 'unit.tests' }), # get all records in newly created zone call('GET', '/domains/unit.tests/records', {'page': 1}), # delete the initial A record call('DELETE', '/domains/unit.tests/records/11189877'), # created at least one of the record with expected data call('POST', '/domains/unit.tests/records', data={ 'name': '_srv._tcp', 'weight': 20, 'data': 'foo-1.unit.tests.', 'priority': 10, 'ttl': 600, 'type': 'SRV', 'port': 30 }), ]) self.assertEquals(24, provider._client._request.call_count) provider._client._request.reset_mock() # delete 1 and update 1 provider._client.records = Mock(return_value=[{ 'id': 11189897, 'name': 'www', 'data': '1.2.3.4', 'ttl': 300, 'type': 'A', }, { 'id': 11189898, 'name': 'www', 'data': '2.2.3.4', 'ttl': 300, 'type': 'A', }, { 'id': 11189899, 'name': 'ttl', 'data': '3.2.3.4', 'ttl': 600, 'type': 'A', }]) # Domain exists, we don't care about return resp.json.side_effect = ['{}'] wanted = Zone('unit.tests.', []) wanted.add_record( Record.new(wanted, 'ttl', { 'ttl': 300, 'type': 'A', 'value': '3.2.3.4' })) plan = provider.plan(wanted) self.assertTrue(plan.exists) self.assertEquals(2, len(plan.changes)) self.assertEquals(2, provider.apply(plan)) # recreate for update, and delete for the 2 parts of the other provider._client._request.assert_has_calls([ call('POST', '/domains/unit.tests/records', data={ 'data': '3.2.3.4', 'type': 'A', 'name': 'ttl', 'ttl': 300 }), call('DELETE', '/domains/unit.tests/records/11189899'), call('DELETE', '/domains/unit.tests/records/11189897'), call('DELETE', '/domains/unit.tests/records/11189898') ], any_order=True)
class TestEasyDNSProvider(TestCase): expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) def test_populate(self): provider = EasyDNSProvider('test', 'token', 'apikey') # Bad auth with requests_mock() as mock: mock.get(ANY, status_code=401, text='{"id":"unauthorized",' '"message":"Unable to authenticate you."}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('Unauthorized', text_type(ctx.exception)) # Bad request with requests_mock() as mock: mock.get(ANY, status_code=400, text='{"id":"invalid",' '"message":"Bad request"}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('Bad request', text_type(ctx.exception)) # General error with requests_mock() as mock: mock.get(ANY, status_code=502, text='Things caught fire') with self.assertRaises(HTTPError) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(502, ctx.exception.response.status_code) # Non-existent zone doesn't populate anything with requests_mock() as mock: mock.get(ANY, status_code=404, text='{"id":"not_found","message":"The resource you ' 'were accessing could not be found."}') zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(set(), zone.records) # No diffs == no changes with requests_mock() as mock: base = 'https://rest.easydns.net/zones/records/' with open('tests/fixtures/easydns-records.json') as fh: mock.get('{}{}'.format(base, 'parsed/unit.tests'), text=fh.read()) with open('tests/fixtures/easydns-records.json') as fh: mock.get('{}{}'.format(base, 'all/unit.tests'), text=fh.read()) provider.populate(zone) self.assertEquals(13, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEquals(0, len(changes)) # 2nd populate makes no network calls/all from cache again = Zone('unit.tests.', []) provider.populate(again) self.assertEquals(13, len(again.records)) # bust the cache del provider._zone_records[zone.name] def test_domain(self): provider = EasyDNSProvider('test', 'token', 'apikey') with requests_mock() as mock: base = 'https://rest.easydns.net/' mock.get('{}{}'.format(base, 'domain/unit.tests'), status_code=400, text='{"id":"not_found","message":"The resource you ' 'were accessing could not be found."}') with self.assertRaises(Exception) as ctx: provider._client.domain('unit.tests') self.assertEquals('Not Found', text_type(ctx.exception)) def test_apply_not_found(self): provider = EasyDNSProvider('test', 'token', 'apikey') wanted = Zone('unit.tests.', []) wanted.add_record(Record.new(wanted, 'test1', { "name": "test1", "ttl": 300, "type": "A", "value": "1.2.3.4", })) with requests_mock() as mock: base = 'https://rest.easydns.net/' mock.get('{}{}'.format(base, 'domain/unit.tests'), status_code=404, text='{"id":"not_found","message":"The resource you ' 'were accessing could not be found."}') mock.put('{}{}'.format(base, 'domains/add/unit.tests'), status_code=200, text='{"id":"OK","message":"Zone created."}') mock.get('{}{}'.format(base, 'zones/records/parsed/unit.tests'), status_code=404, text='{"id":"not_found","message":"The resource you ' 'were accessing could not be found."}') mock.get('{}{}'.format(base, 'zones/records/all/unit.tests'), status_code=404, text='{"id":"not_found","message":"The resource you ' 'were accessing could not be found."}') plan = provider.plan(wanted) self.assertFalse(plan.exists) self.assertEquals(1, len(plan.changes)) with self.assertRaises(Exception) as ctx: provider.apply(plan) self.assertEquals('Not Found', text_type(ctx.exception)) def test_domain_create(self): provider = EasyDNSProvider('test', 'token', 'apikey') domain_after_creation = { "tm": 1000000000, "data": [{ "id": "12341001", "domain": "unit.tests", "host": "@", "ttl": "0", "prio": "0", "type": "SOA", "rdata": "dns1.easydns.com. zone.easydns.com. " "2020010101 3600 600 604800 0", "geozone_id": "0", "last_mod": "2020-01-01 01:01:01" }, { "id": "12341002", "domain": "unit.tests", "host": "@", "ttl": "0", "prio": "0", "type": "NS", "rdata": "LOCAL.", "geozone_id": "0", "last_mod": "2020-01-01 01:01:01" }, { "id": "12341003", "domain": "unit.tests", "host": "@", "ttl": "0", "prio": "0", "type": "MX", "rdata": "LOCAL.", "geozone_id": "0", "last_mod": "2020-01-01 01:01:01" }], "count": 3, "total": 3, "start": 0, "max": 1000, "status": 200 } with requests_mock() as mock: base = 'https://rest.easydns.net/' mock.put('{}{}'.format(base, 'domains/add/unit.tests'), status_code=201, text='{"id":"OK"}') mock.get('{}{}'.format(base, 'zones/records/all/unit.tests'), text=json.dumps(domain_after_creation)) mock.delete(ANY, text='{"id":"OK"}') provider._client.domain_create('unit.tests') def test_caa(self): provider = EasyDNSProvider('test', 'token', 'apikey') # Invalid rdata records caa_record_invalid = [{ "domain": "unit.tests", "host": "@", "ttl": "3600", "prio": "0", "type": "CAA", "rdata": "0", }] # Valid rdata records caa_record_valid = [{ "domain": "unit.tests", "host": "@", "ttl": "3600", "prio": "0", "type": "CAA", "rdata": "0 issue ca.unit.tests", }] provider._data_for_CAA('CAA', caa_record_invalid) provider._data_for_CAA('CAA', caa_record_valid) def test_naptr(self): provider = EasyDNSProvider('test', 'token', 'apikey') # Invalid rdata records naptr_record_invalid = [{ "domain": "unit.tests", "host": "naptr", "ttl": "600", "prio": "10", "type": "NAPTR", "rdata": "100", }] # Valid rdata records naptr_record_valid = [{ "domain": "unit.tests", "host": "naptr", "ttl": "600", "prio": "10", "type": "NAPTR", "rdata": "10 10 'U' 'SIP+D2U' '!^.*$!sip:[email protected]!' .", }] provider._data_for_NAPTR('NAPTR', naptr_record_invalid) provider._data_for_NAPTR('NAPTR', naptr_record_valid) def test_srv(self): provider = EasyDNSProvider('test', 'token', 'apikey') # Invalid rdata records srv_invalid = [{ "domain": "unit.tests", "host": "_srv._tcp", "ttl": "600", "type": "SRV", "rdata": "", }] srv_invalid2 = [{ "domain": "unit.tests", "host": "_srv._tcp", "ttl": "600", "type": "SRV", "rdata": "11", }] srv_invalid3 = [{ "domain": "unit.tests", "host": "_srv._tcp", "ttl": "600", "type": "SRV", "rdata": "12 30", }] srv_invalid4 = [{ "domain": "unit.tests", "host": "_srv._tcp", "ttl": "600", "type": "SRV", "rdata": "13 40 1234", }] # Valid rdata srv_valid = [{ "domain": "unit.tests", "host": "_srv._tcp", "ttl": "600", "type": "SRV", "rdata": "100 20 5678 foo-2.unit.tests.", }] srv_invalid_content = provider._data_for_SRV('SRV', srv_invalid) srv_invalid_content2 = provider._data_for_SRV('SRV', srv_invalid2) srv_invalid_content3 = provider._data_for_SRV('SRV', srv_invalid3) srv_invalid_content4 = provider._data_for_SRV('SRV', srv_invalid4) srv_valid_content = provider._data_for_SRV('SRV', srv_valid) self.assertEqual(srv_valid_content['values'][0]['priority'], 100) self.assertEqual(srv_invalid_content['values'][0]['priority'], 0) self.assertEqual(srv_invalid_content2['values'][0]['priority'], 11) self.assertEqual(srv_invalid_content3['values'][0]['priority'], 12) self.assertEqual(srv_invalid_content4['values'][0]['priority'], 13) self.assertEqual(srv_valid_content['values'][0]['weight'], 20) self.assertEqual(srv_invalid_content['values'][0]['weight'], 0) self.assertEqual(srv_invalid_content2['values'][0]['weight'], 0) self.assertEqual(srv_invalid_content3['values'][0]['weight'], 30) self.assertEqual(srv_invalid_content4['values'][0]['weight'], 40) self.assertEqual(srv_valid_content['values'][0]['port'], 5678) self.assertEqual(srv_invalid_content['values'][0]['port'], 0) self.assertEqual(srv_invalid_content2['values'][0]['port'], 0) self.assertEqual(srv_invalid_content3['values'][0]['port'], 0) self.assertEqual(srv_invalid_content4['values'][0]['port'], 1234) self.assertEqual(srv_valid_content['values'][0]['target'], 'foo-2.unit.tests.') self.assertEqual(srv_invalid_content['values'][0]['target'], '') self.assertEqual(srv_invalid_content2['values'][0]['target'], '') self.assertEqual(srv_invalid_content3['values'][0]['target'], '') self.assertEqual(srv_invalid_content4['values'][0]['target'], '') def test_apply(self): provider = EasyDNSProvider('test', 'token', 'apikey') resp = Mock() resp.json = Mock() provider._client._request = Mock(return_value=resp) domain_after_creation = { "tm": 1000000000, "data": [{ "id": "12341001", "domain": "unit.tests", "host": "@", "ttl": "0", "prio": "0", "type": "SOA", "rdata": "dns1.easydns.com. zone.easydns.com. 2020010101" " 3600 600 604800 0", "geozone_id": "0", "last_mod": "2020-01-01 01:01:01" }, { "id": "12341002", "domain": "unit.tests", "host": "@", "ttl": "0", "prio": "0", "type": "NS", "rdata": "LOCAL.", "geozone_id": "0", "last_mod": "2020-01-01 01:01:01" }, { "id": "12341003", "domain": "unit.tests", "host": "@", "ttl": "0", "prio": "0", "type": "MX", "rdata": "LOCAL.", "geozone_id": "0", "last_mod": "2020-01-01 01:01:01" }], "count": 3, "total": 3, "start": 0, "max": 1000, "status": 200 } # non-existent domain, create everything resp.json.side_effect = [ EasyDNSClientNotFound, # no zone in populate domain_after_creation ] plan = provider.plan(self.expected) # No root NS, no ignored, no excluded, no unsupported n = len(self.expected.records) - 7 self.assertEquals(n, len(plan.changes)) self.assertEquals(n, provider.apply(plan)) self.assertFalse(plan.exists) self.assertEquals(23, provider._client._request.call_count) provider._client._request.reset_mock() # delete 1 and update 1 provider._client.records = Mock(return_value=[ { "id": "12342001", "domain": "unit.tests", "host": "www", "ttl": "300", "prio": "0", "type": "A", "rdata": "2.2.3.9", "geozone_id": "0", "last_mod": "2020-01-01 01:01:01" }, { "id": "12342002", "domain": "unit.tests", "host": "www", "ttl": "300", "prio": "0", "type": "A", "rdata": "2.2.3.8", "geozone_id": "0", "last_mod": "2020-01-01 01:01:01" }, { "id": "12342003", "domain": "unit.tests", "host": "test1", "ttl": "3600", "prio": "0", "type": "A", "rdata": "1.2.3.4", "geozone_id": "0", "last_mod": "2020-01-01 01:01:01" } ]) # Domain exists, we don't care about return resp.json.side_effect = ['{}'] wanted = Zone('unit.tests.', []) wanted.add_record(Record.new(wanted, 'test1', { "name": "test1", "ttl": 300, "type": "A", "value": "1.2.3.4", })) plan = provider.plan(wanted) self.assertTrue(plan.exists) self.assertEquals(2, len(plan.changes)) self.assertEquals(2, provider.apply(plan)) # recreate for update, and delete for the 2 parts of the other provider._client._request.assert_has_calls([ call('PUT', '/zones/records/add/unit.tests/A', data={ 'rdata': '1.2.3.4', 'name': 'test1', 'ttl': 300, 'type': 'A', 'host': 'test1', }), call('DELETE', '/zones/records/unit.tests/12342001'), call('DELETE', '/zones/records/unit.tests/12342002'), call('DELETE', '/zones/records/unit.tests/12342003') ], any_order=True)
class TestEdgeDnsProvider(TestCase): expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) # Our test suite differs a bit, add our NS and remove the simple one expected.add_record( Record.new( expected, 'under', { 'ttl': 3600, 'type': 'NS', 'values': [ 'ns1.unit.tests.', 'ns2.unit.tests.', ] })) for record in list(expected.records): if record.name == 'sub' and record._type == 'NS': expected._remove_record(record) break def test_populate(self): provider = AkamaiProvider("test", "secret", "akam.com", "atok", "ctok") # Bad Auth with requests_mock() as mock: mock.get(ANY, status_code=401, text='{"message": "Unauthorized"}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(401, ctx.exception.response.status_code) # general error with requests_mock() as mock: mock.get(ANY, status_code=502, text='Things caught fire') with self.assertRaises(HTTPError) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(502, ctx.exception.response.status_code) # Non-existant zone doesn't populate anything with requests_mock() as mock: mock.get(ANY, status_code=404, text='{"message": "Domain `foo.bar` not found"}') zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(set(), zone.records) # No diffs == no changes with requests_mock() as mock: with open('tests/fixtures/edgedns-records.json') as fh: mock.get(ANY, text=fh.read()) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(18, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEquals(0, len(changes)) # 2nd populate makes no network calls/all from cache again = Zone('unit.tests.', []) provider.populate(again) self.assertEquals(18, len(again.records)) # bust the cache del provider._zone_records[zone.name] def test_apply(self): provider = AkamaiProvider("test", "s", "akam.com", "atok", "ctok", "cid", "gid") # tests create update delete through previous state config json with requests_mock() as mock: with open('tests/fixtures/edgedns-records-prev.json') as fh: mock.get(ANY, text=fh.read()) plan = provider.plan(self.expected) mock.post(ANY, status_code=201) mock.put(ANY, status_code=200) mock.delete(ANY, status_code=204) changes = provider.apply(plan) self.assertEquals(31, changes) # Test against a zone that doesn't exist yet with requests_mock() as mock: with open('tests/fixtures/edgedns-records-prev-other.json') as fh: mock.get(ANY, status_code=404) plan = provider.plan(self.expected) mock.post(ANY, status_code=201) mock.put(ANY, status_code=200) mock.delete(ANY, status_code=204) changes = provider.apply(plan) self.assertEquals(16, changes) # Test against a zone that doesn't exist yet, but gid not provided with requests_mock() as mock: with open('tests/fixtures/edgedns-records-prev-other.json') as fh: mock.get(ANY, status_code=404) provider = AkamaiProvider("test", "s", "akam.com", "atok", "ctok", "cid") plan = provider.plan(self.expected) mock.post(ANY, status_code=201) mock.put(ANY, status_code=200) mock.delete(ANY, status_code=204) changes = provider.apply(plan) self.assertEquals(16, changes) # Test against a zone that doesn't exist, but cid not provided with requests_mock() as mock: mock.get(ANY, status_code=404) provider = AkamaiProvider("test", "s", "akam.com", "atok", "ctok") plan = provider.plan(self.expected) mock.post(ANY, status_code=201) mock.put(ANY, status_code=200) mock.delete(ANY, status_code=204) try: changes = provider.apply(plan) except NameError as e: expected = "contractId not specified to create zone" self.assertEquals(text_type(e), expected)
class TestCloudflareProvider(TestCase): expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) # Our test suite differs a bit, add our NS and remove the simple one expected.add_record( Record.new( expected, 'under', { 'ttl': 3600, 'type': 'NS', 'values': [ 'ns1.unit.tests.', 'ns2.unit.tests.', ] })) for record in list(expected.records): if record.name == 'sub' and record._type == 'NS': expected._remove_record(record) break empty = {'result': [], 'result_info': {'count': 0, 'per_page': 0}} def test_populate(self): provider = CloudflareProvider('test', 'email', 'token') # Bad requests with requests_mock() as mock: mock.get(ANY, status_code=400, text='{"success":false,"errors":[{"code":1101,' '"message":"request was invalid"}],' '"messages":[],"result":null}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('CloudflareError', type(ctx.exception).__name__) self.assertEquals('request was invalid', ctx.exception.message) # Bad auth with requests_mock() as mock: mock.get(ANY, status_code=403, text='{"success":false,"errors":[{"code":9103,' '"message":"Unknown X-Auth-Key or X-Auth-Email"}],' '"messages":[],"result":null}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('CloudflareAuthenticationError', type(ctx.exception).__name__) self.assertEquals('Unknown X-Auth-Key or X-Auth-Email', ctx.exception.message) # Bad auth, unknown resp with requests_mock() as mock: mock.get(ANY, status_code=403, text='{}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('CloudflareAuthenticationError', type(ctx.exception).__name__) self.assertEquals('Cloudflare error', ctx.exception.message) # General error with requests_mock() as mock: mock.get(ANY, status_code=502, text='Things caught fire') with self.assertRaises(HTTPError) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(502, ctx.exception.response.status_code) # Non-existant zone doesn't populate anything with requests_mock() as mock: mock.get(ANY, status_code=200, json=self.empty) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(set(), zone.records) # re-populating the same non-existant zone uses cache and makes no # calls again = Zone('unit.tests.', []) provider.populate(again) self.assertEquals(set(), again.records) # bust zone cache provider._zones = None # existing zone with data with requests_mock() as mock: base = 'https://api.cloudflare.com/client/v4/zones' # zones with open('tests/fixtures/cloudflare-zones-page-1.json') as fh: mock.get('{}?page=1'.format(base), status_code=200, text=fh.read()) with open('tests/fixtures/cloudflare-zones-page-2.json') as fh: mock.get('{}?page=2'.format(base), status_code=200, text=fh.read()) mock.get('{}?page=3'.format(base), status_code=200, json={ 'result': [], 'result_info': { 'count': 0, 'per_page': 0 } }) # records base = '{}/234234243423aaabb334342aaa343435/dns_records' \ .format(base) with open('tests/fixtures/cloudflare-dns_records-' 'page-1.json') as fh: mock.get('{}?page=1'.format(base), status_code=200, text=fh.read()) with open('tests/fixtures/cloudflare-dns_records-' 'page-2.json') as fh: mock.get('{}?page=2'.format(base), status_code=200, text=fh.read()) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(12, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEquals(0, len(changes)) # re-populating the same zone/records comes out of cache, no calls again = Zone('unit.tests.', []) provider.populate(again) self.assertEquals(12, len(again.records)) def test_apply(self): provider = CloudflareProvider('test', 'email', 'token') provider._request = Mock() provider._request.side_effect = [ self.empty, # no zones { 'result': { 'id': 42, } }, # zone create ] + [None] * 20 # individual record creates # non-existant zone, create everything plan = provider.plan(self.expected) self.assertEquals(12, len(plan.changes)) self.assertEquals(12, provider.apply(plan)) provider._request.assert_has_calls( [ # created the domain call('POST', '/zones', data={ 'jump_start': False, 'name': 'unit.tests' }), # created at least one of the record with expected data call('POST', '/zones/42/dns_records', data={ 'content': 'ns1.unit.tests.', 'type': 'NS', 'name': 'under.unit.tests', 'ttl': 3600 }), # make sure semicolons are not escaped when sending data call('POST', '/zones/42/dns_records', data={ 'content': 'v=DKIM1;k=rsa;s=email;h=sha256;' 'p=A/kinda+of/long/string+with+numb3rs', 'type': 'TXT', 'name': 'txt.unit.tests', 'ttl': 600 }), ], True) # expected number of total calls self.assertEquals(22, provider._request.call_count) provider._request.reset_mock() provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997653", "type": "A", "name": "www.unit.tests", "content": "1.2.3.4", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997654", "type": "A", "name": "www.unit.tests", "content": "2.2.3.4", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:44.030044Z", "created_on": "2017-03-11T18:01:44.030044Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997655", "type": "A", "name": "nc.unit.tests", "content": "3.2.3.4", "proxiable": True, "proxied": False, "ttl": 120, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:44.030044Z", "created_on": "2017-03-11T18:01:44.030044Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997655", "type": "A", "name": "ttl.unit.tests", "content": "4.2.3.4", "proxiable": True, "proxied": False, "ttl": 600, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:44.030044Z", "created_on": "2017-03-11T18:01:44.030044Z", "meta": { "auto_added": False } }, ]) # we don't care about the POST/create return values provider._request.return_value = {} provider._request.side_effect = None wanted = Zone('unit.tests.', []) wanted.add_record( Record.new( wanted, 'nc', { 'ttl': 60, # TTL is below their min 'type': 'A', 'value': '3.2.3.4' })) wanted.add_record( Record.new( wanted, 'ttl', { 'ttl': 300, # TTL change 'type': 'A', 'value': '3.2.3.4' })) plan = provider.plan(wanted) # only see the delete & ttl update, below min-ttl is filtered out self.assertEquals(2, len(plan.changes)) self.assertEquals(2, provider.apply(plan)) # recreate for update, and deletes for the 2 parts of the other provider._request.assert_has_calls([ call('PUT', '/zones/ff12ab34cd5611334422ab3322997650/dns_records/' 'fc12ab34cd5611334422ab3322997655', data={ 'content': '3.2.3.4', 'type': 'A', 'name': 'ttl.unit.tests', 'ttl': 300 }), call( 'DELETE', '/zones/ff12ab34cd5611334422ab3322997650/' 'dns_records/fc12ab34cd5611334422ab3322997653'), call( 'DELETE', '/zones/ff12ab34cd5611334422ab3322997650/' 'dns_records/fc12ab34cd5611334422ab3322997654') ]) def test_update_add_swap(self): provider = CloudflareProvider('test', 'email', 'token') provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997653", "type": "A", "name": "a.unit.tests", "content": "1.1.1.1", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997654", "type": "A", "name": "a.unit.tests", "content": "2.2.2.2", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, ]) provider._request = Mock() provider._request.side_effect = [ self.empty, # no zones { 'result': { 'id': 42, } }, # zone create None, None, ] # Add something and delete something zone = Zone('unit.tests.', []) existing = Record.new( zone, 'a', { 'ttl': 300, 'type': 'A', # This matches the zone data above, one to swap, one to leave 'values': ['1.1.1.1', '2.2.2.2'], }) new = Record.new( zone, 'a', { 'ttl': 300, 'type': 'A', # This leaves one, swaps ones, and adds one 'values': ['2.2.2.2', '3.3.3.3', '4.4.4.4'], }) change = Update(existing, new) plan = Plan(zone, zone, [change]) provider._apply(plan) provider._request.assert_has_calls([ call('GET', '/zones', params={'page': 1}), call('POST', '/zones', data={ 'jump_start': False, 'name': 'unit.tests' }), call('PUT', '/zones/ff12ab34cd5611334422ab3322997650/dns_records/' 'fc12ab34cd5611334422ab3322997653', data={ 'content': '4.4.4.4', 'type': 'A', 'name': 'a.unit.tests', 'ttl': 300 }), call('POST', '/zones/42/dns_records', data={ 'content': '3.3.3.3', 'type': 'A', 'name': 'a.unit.tests', 'ttl': 300 }) ]) def test_update_delete(self): # We need another run so that we can delete, we can't both add and # delete in one go b/c of swaps provider = CloudflareProvider('test', 'email', 'token') provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997653", "type": "NS", "name": "unit.tests", "content": "ns1.foo.bar", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997654", "type": "NS", "name": "unit.tests", "content": "ns2.foo.bar", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, ]) provider._request = Mock() provider._request.side_effect = [ self.empty, # no zones { 'result': { 'id': 42, } }, # zone create None, None, ] # Add something and delete something zone = Zone('unit.tests.', []) existing = Record.new( zone, '', { 'ttl': 300, 'type': 'NS', # This matches the zone data above, one to delete, one to leave 'values': ['ns1.foo.bar.', 'ns2.foo.bar.'], }) new = Record.new( zone, '', { 'ttl': 300, 'type': 'NS', # This leaves one and deletes one 'value': 'ns2.foo.bar.', }) change = Update(existing, new) plan = Plan(zone, zone, [change]) provider._apply(plan) provider._request.assert_has_calls([ call('GET', '/zones', params={'page': 1}), call('POST', '/zones', data={ 'jump_start': False, 'name': 'unit.tests' }), call( 'DELETE', '/zones/ff12ab34cd5611334422ab3322997650/' 'dns_records/fc12ab34cd5611334422ab3322997653') ]) def test_alias(self): provider = CloudflareProvider('test', 'email', 'token') # A CNAME for us to transform to ALIAS provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997642", "type": "CNAME", "name": "unit.tests", "content": "www.unit.tests", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, ]) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(1, len(zone.records)) record = list(zone.records)[0] self.assertEquals('', record.name) self.assertEquals('unit.tests.', record.fqdn) self.assertEquals('ALIAS', record._type) self.assertEquals('www.unit.tests.', record.value) # Make sure we transform back to CNAME going the other way contents = provider._gen_contents(record) self.assertEquals( { 'content': u'www.unit.tests.', 'name': 'unit.tests', 'ttl': 300, 'type': 'CNAME' }, list(contents)[0]) def test_cdn(self): provider = CloudflareProvider('test', 'email', 'token', True) # A CNAME for us to transform to ALIAS provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997642", "type": "CNAME", "name": "cname.unit.tests", "content": "www.unit.tests", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997642", "type": "A", "name": "a.unit.tests", "content": "1.1.1.1", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997642", "type": "A", "name": "a.unit.tests", "content": "1.1.1.2", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997642", "type": "A", "name": "multi.unit.tests", "content": "1.1.1.3", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997642", "type": "AAAA", "name": "multi.unit.tests", "content": "::1", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, ]) zone = Zone('unit.tests.', []) provider.populate(zone) # the two A records get merged into one CNAME record poining to the CDN self.assertEquals(3, len(zone.records)) record = list(zone.records)[0] self.assertEquals('multi', record.name) self.assertEquals('multi.unit.tests.', record.fqdn) self.assertEquals('CNAME', record._type) self.assertEquals('multi.unit.tests.cdn.cloudflare.net.', record.value) record = list(zone.records)[1] self.assertEquals('cname', record.name) self.assertEquals('cname.unit.tests.', record.fqdn) self.assertEquals('CNAME', record._type) self.assertEquals('cname.unit.tests.cdn.cloudflare.net.', record.value) record = list(zone.records)[2] self.assertEquals('a', record.name) self.assertEquals('a.unit.tests.', record.fqdn) self.assertEquals('CNAME', record._type) self.assertEquals('a.unit.tests.cdn.cloudflare.net.', record.value) # CDN enabled records can't be updated, we don't know the real values # never point a Cloudflare record to itsself. wanted = Zone('unit.tests.', []) wanted.add_record( Record.new( wanted, 'cname', { 'ttl': 300, 'type': 'CNAME', 'value': 'change.unit.tests.cdn.cloudflare.net.' })) wanted.add_record( Record.new( wanted, 'new', { 'ttl': 300, 'type': 'CNAME', 'value': 'new.unit.tests.cdn.cloudflare.net.' })) wanted.add_record( Record.new(wanted, 'created', { 'ttl': 300, 'type': 'CNAME', 'value': 'www.unit.tests.' })) plan = provider.plan(wanted) self.assertEquals(1, len(plan.changes)) def test_cdn_alias(self): provider = CloudflareProvider('test', 'email', 'token', True) # A CNAME for us to transform to ALIAS provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997642", "type": "CNAME", "name": "unit.tests", "content": "www.unit.tests", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, ]) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(1, len(zone.records)) record = list(zone.records)[0] self.assertEquals('', record.name) self.assertEquals('unit.tests.', record.fqdn) self.assertEquals('ALIAS', record._type) self.assertEquals('unit.tests.cdn.cloudflare.net.', record.value) # CDN enabled records can't be updated, we don't know the real values # never point a Cloudflare record to itsself. wanted = Zone('unit.tests.', []) wanted.add_record( Record.new( wanted, '', { 'ttl': 300, 'type': 'ALIAS', 'value': 'change.unit.tests.cdn.cloudflare.net.' })) plan = provider.plan(wanted) self.assertEquals(False, hasattr(plan, 'changes'))
def test_provider(self): provider = PowerDnsProvider('test', 'non.existent', 'api-key', nameserver_values=['8.8.8.8.', '9.9.9.9.']) # Bad auth with requests_mock() as mock: mock.get(ANY, status_code=401, text='Unauthorized') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertTrue('unauthorized' in text_type(ctx.exception)) # General error with requests_mock() as mock: mock.get(ANY, status_code=502, text='Things caught fire') with self.assertRaises(HTTPError) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(502, ctx.exception.response.status_code) # Non-existent zone doesn't populate anything with requests_mock() as mock: mock.get(ANY, status_code=422, json={'error': "Could not find domain 'unit.tests.'"}) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(set(), zone.records) # The rest of this is messy/complicated b/c it's dealing with mocking expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) expected_n = len(expected.records) - 2 self.assertEquals(16, expected_n) # No diffs == no changes with requests_mock() as mock: mock.get(ANY, status_code=200, text=FULL_TEXT) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(16, len(zone.records)) changes = expected.changes(zone, provider) self.assertEquals(0, len(changes)) # Used in a minute def assert_rrsets_callback(request, context): data = loads(request.body) self.assertEquals(expected_n, len(data['rrsets'])) return '' # No existing records -> creates for every record in expected with requests_mock() as mock: mock.get(ANY, status_code=200, text=EMPTY_TEXT) # post 201, is response to the create with data mock.patch(ANY, status_code=201, text=assert_rrsets_callback) plan = provider.plan(expected) self.assertEquals(expected_n, len(plan.changes)) self.assertEquals(expected_n, provider.apply(plan)) self.assertTrue(plan.exists) # Non-existent zone -> creates for every record in expected # OMG this is f*****g ugly, probably better to ditch requests_mocks and # just mock things for real as it doesn't seem to provide a way to get # at the request params or verify that things were called from what I # can tell not_found = {'error': "Could not find domain 'unit.tests.'"} with requests_mock() as mock: # get 422's, unknown zone mock.get(ANY, status_code=422, text='') # patch 422's, unknown zone mock.patch(ANY, status_code=422, text=dumps(not_found)) # post 201, is response to the create with data mock.post(ANY, status_code=201, text=assert_rrsets_callback) plan = provider.plan(expected) self.assertEquals(expected_n, len(plan.changes)) self.assertEquals(expected_n, provider.apply(plan)) self.assertFalse(plan.exists) with requests_mock() as mock: # get 422's, unknown zone mock.get(ANY, status_code=422, text='') # patch 422's, data = {'error': "Key 'name' not present or not a String"} mock.patch(ANY, status_code=422, text=dumps(data)) with self.assertRaises(HTTPError) as ctx: plan = provider.plan(expected) provider.apply(plan) response = ctx.exception.response self.assertEquals(422, response.status_code) self.assertTrue('error' in response.json()) with requests_mock() as mock: # get 422's, unknown zone mock.get(ANY, status_code=422, text='') # patch 500's, things just blew up mock.patch(ANY, status_code=500, text='') with self.assertRaises(HTTPError): plan = provider.plan(expected) provider.apply(plan) with requests_mock() as mock: # get 422's, unknown zone mock.get(ANY, status_code=422, text='') # patch 500's, things just blew up mock.patch(ANY, status_code=422, text=dumps(not_found)) # post 422's, something wrong with create mock.post(ANY, status_code=422, text='Hello Word!') with self.assertRaises(HTTPError): plan = provider.plan(expected) provider.apply(plan)
class TestCloudflareProvider(TestCase): expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) # Our test suite differs a bit, add our NS and remove the simple one expected.add_record( Record.new( expected, 'under', { 'ttl': 3600, 'type': 'NS', 'values': [ 'ns1.unit.tests.', 'ns2.unit.tests.', ] })) for record in list(expected.records): if record.name == 'sub' and record._type == 'NS': expected._remove_record(record) break empty = {'result': [], 'result_info': {'count': 0, 'per_page': 0}} def test_populate(self): provider = CloudflareProvider('test', 'email', 'token') # Bad auth with requests_mock() as mock: mock.get(ANY, status_code=403, text='{"success":false,"errors":[{"code":9103,' '"message":"Unknown X-Auth-Key or X-Auth-Email"}],' '"messages":[],"result":null}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('Unknown X-Auth-Key or X-Auth-Email', ctx.exception.message) # Bad auth, unknown resp with requests_mock() as mock: mock.get(ANY, status_code=403, text='{}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('Authentication error', ctx.exception.message) # General error with requests_mock() as mock: mock.get(ANY, status_code=502, text='Things caught fire') with self.assertRaises(HTTPError) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(502, ctx.exception.response.status_code) # Non-existant zone doesn't populate anything with requests_mock() as mock: mock.get(ANY, status_code=200, json=self.empty) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(set(), zone.records) # re-populating the same non-existant zone uses cache and makes no # calls again = Zone('unit.tests.', []) provider.populate(again) self.assertEquals(set(), again.records) # bust zone cache provider._zones = None # existing zone with data with requests_mock() as mock: base = 'https://api.cloudflare.com/client/v4/zones' # zones with open('tests/fixtures/cloudflare-zones-page-1.json') as fh: mock.get('{}?page=1'.format(base), status_code=200, text=fh.read()) with open('tests/fixtures/cloudflare-zones-page-2.json') as fh: mock.get('{}?page=2'.format(base), status_code=200, text=fh.read()) mock.get('{}?page=3'.format(base), status_code=200, json={ 'result': [], 'result_info': { 'count': 0, 'per_page': 0 } }) # records base = '{}/234234243423aaabb334342aaa343435/dns_records' \ .format(base) with open('tests/fixtures/cloudflare-dns_records-' 'page-1.json') as fh: mock.get('{}?page=1'.format(base), status_code=200, text=fh.read()) with open('tests/fixtures/cloudflare-dns_records-' 'page-2.json') as fh: mock.get('{}?page=2'.format(base), status_code=200, text=fh.read()) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(9, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEquals(0, len(changes)) # re-populating the same zone/records comes out of cache, no calls again = Zone('unit.tests.', []) provider.populate(again) self.assertEquals(9, len(again.records)) def test_apply(self): provider = CloudflareProvider('test', 'email', 'token') provider._request = Mock() provider._request.side_effect = [ self.empty, # no zones { 'result': { 'id': 42, } }, # zone create ] + [None] * 16 # individual record creates # non-existant zone, create everything plan = provider.plan(self.expected) self.assertEquals(9, len(plan.changes)) self.assertEquals(9, provider.apply(plan)) provider._request.assert_has_calls( [ # created the domain call('POST', '/zones', data={ 'jump_start': False, 'name': 'unit.tests' }), # created at least one of the record with expected data call('POST', '/zones/42/dns_records', data={ 'content': 'ns1.unit.tests.', 'type': 'NS', 'name': 'under.unit.tests', 'ttl': 3600 }), # make sure semicolons are not escaped when sending data call('POST', '/zones/42/dns_records', data={ 'content': 'v=DKIM1;k=rsa;s=email;h=sha256;' 'p=A/kinda+of/long/string+with+numb3rs', 'type': 'TXT', 'name': 'txt.unit.tests', 'ttl': 600 }), ], True) # expected number of total calls self.assertEquals(18, provider._request.call_count) provider._request.reset_mock() provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997653", "type": "A", "name": "www.unit.tests", "content": "1.2.3.4", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997654", "type": "A", "name": "www.unit.tests", "content": "2.2.3.4", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:44.030044Z", "created_on": "2017-03-11T18:01:44.030044Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997655", "type": "A", "name": "nc.unit.tests", "content": "3.2.3.4", "proxiable": True, "proxied": False, "ttl": 120, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:44.030044Z", "created_on": "2017-03-11T18:01:44.030044Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997655", "type": "A", "name": "ttl.unit.tests", "content": "4.2.3.4", "proxiable": True, "proxied": False, "ttl": 600, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:44.030044Z", "created_on": "2017-03-11T18:01:44.030044Z", "meta": { "auto_added": False } }, ]) # we don't care about the POST/create return values provider._request.return_value = {} provider._request.side_effect = None wanted = Zone('unit.tests.', []) wanted.add_record( Record.new( wanted, 'nc', { 'ttl': 60, # TTL is below their min 'type': 'A', 'value': '3.2.3.4' })) wanted.add_record( Record.new( wanted, 'ttl', { 'ttl': 300, # TTL change 'type': 'A', 'value': '3.2.3.4' })) plan = provider.plan(wanted) # only see the delete & ttl update, below min-ttl is filtered out self.assertEquals(2, len(plan.changes)) self.assertEquals(2, provider.apply(plan)) # recreate for update, and deletes for the 2 parts of the other provider._request.assert_has_calls([ call('POST', '/zones/42/dns_records', data={ 'content': '3.2.3.4', 'type': 'A', 'name': 'ttl.unit.tests', 'ttl': 300 }), call( 'DELETE', '/zones/ff12ab34cd5611334422ab3322997650/' 'dns_records/fc12ab34cd5611334422ab3322997655'), call( 'DELETE', '/zones/ff12ab34cd5611334422ab3322997650/' 'dns_records/fc12ab34cd5611334422ab3322997653'), call( 'DELETE', '/zones/ff12ab34cd5611334422ab3322997650/' 'dns_records/fc12ab34cd5611334422ab3322997654') ])
class TestConstellixProvider(TestCase): expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) # Our test suite differs a bit, add our NS and remove the simple one expected.add_record( Record.new( expected, 'under', { 'ttl': 3600, 'type': 'NS', 'values': [ 'ns1.unit.tests.', 'ns2.unit.tests.', ] })) # Add some ALIAS records expected.add_record( Record.new(expected, '', { 'ttl': 1800, 'type': 'ALIAS', 'value': 'aname.unit.tests.' })) expected.add_record( Record.new(expected, 'sub', { 'ttl': 1800, 'type': 'ALIAS', 'value': 'aname.unit.tests.' })) for record in list(expected.records): if record.name == 'sub' and record._type == 'NS': expected._remove_record(record) break @staticmethod def _fixture(filename): return join(join(dirname(__file__), 'fixtures'), filename) @staticmethod def json_func(json_element): def json_inner_func(): return json_element return json_inner_func def test_populate(self): provider = ConstellixProvider('test', 'api', 'secret', ratelimit_delay=0.2) # Bad auth with requests_mock() as mock: mock.get(ANY, status_code=401, text='{"error": ["API key not found"]}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('Unauthorized', text_type(ctx.exception)) # Bad request with requests_mock() as mock: mock.get(ANY, status_code=400, text='{"error": ["Rate limit exceeded"]}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('\n - "unittests" is not a valid domain name', text_type(ctx.exception)) # General error with requests_mock() as mock: mock.get(ANY, status_code=502, text='Things caught fire') with self.assertRaises(HTTPError) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(502, ctx.exception.response.status_code) # Non-existent zone doesn't populate anything with requests_mock() as mock: mock.get(ANY, status_code=404, text='<html><head></head><body></body></html>') zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(set(), zone.records) # No diffs == no changes with requests_mock() as mock: base = 'https://api.dns.constellix.com/v1' with open(self._fixture('constellix-domains.json')) as fh: mock.get('{}{}'.format(base, '/domains'), text=fh.read()) with open(self._fixture('constellix-records.json')) as fh: mock.get('{}{}'.format(base, '/domains/123123/records'), text=fh.read()) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(22, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEquals(26, len(changes)) # 2nd populate makes no network calls/all from cache again = Zone('unit.tests.', []) provider.populate(again) self.assertEquals(22, len(again.records)) # bust the cache del provider._zone_records[zone.name] def test_apply(self): # Create provider with sandbox enabled provider = ConstellixProvider('test', 'api', 'secret', True) resp = Mock() resp.json = Mock() provider._client._request = Mock(return_value=resp) with open(self._fixture('constellix-domains.json')) as fh: domains = json.load(fh) # non-existent domain, create everything resp.json.side_effect = [ ConstellixClientNotFound, # no zone in populate ConstellixClientNotFound, # no domain during apply domains ] plan = provider.plan(self.expected) # No root NS, no ignored, no excluded, no unsupported n = len(self.expected.records) - 4 self.assertEquals(n, len(plan.changes)) self.assertEquals(n, provider.apply(plan)) provider._client._request.assert_has_calls([ # get all domains to build the cache call('GET', '/'), ]) # These two checks are broken up so that ordering doesn't break things. # Python3 doesn't make the calls in a consistent order so different # things follow the GET / on different runs provider._client._request.assert_has_calls([ call('POST', '/123123/records/SRV', data={ 'roundRobin': [{ 'priority': 10, 'weight': 20, 'value': 'foo-1.unit.tests.', 'port': 30 }, { u'priority': 12, u'weight': 20, u'value': 'foo-2.unit.tests.', u'port': 30 }], u'name': u'_srv._tcp', u'ttl': 600 }), call(u'POST', u'/domains/123123/records/SRV', data={ u'roundRobin': [{ u'priority': 10, u'weight': 20, u'value': 'foo-1.unit.tests.', u'port': 30 }, { u'priority': 12, u'weight': 20, u'value': 'foo-2.unit.tests.', u'port': 30 }], u'name': u'_srv._tcp', u'ttl': 600 }), call(u'POST', u'/domains/123123/records/CNAME', data={ u'host': 'unit.tests.', u'name': u'cname', u'ttl': 300 }), call(u'POST', u'/domains/123123/records/NS', data={ u'roundRobin': [{ u'value': u'ns1.unit.tests.' }, { u'value': u'ns2.unit.tests.' }], u'name': u'under', u'ttl': 3600 }), call(u'POST', u'/domains/123123/records/TXT', data={ u'roundRobin': [{ u'value': u'"Bah bah black sheep"' }, { u'value': u'"have you any wool."' }, { u'value': u'"v=DKIM1;k=rsa;s=email;h=sha256;' + \ 'p=A/kinda+of/long/string+with+numb3rs"' }], u'name': u'txt', u'ttl': 600 }), call(u'POST', u'/domains/123123/records/ANAME', data={ u'roundRobin': [{ u'disableFlag': False, u'value': u'aname.unit.tests.' }], u'name': u'sub', u'ttl': 1800 }), call(u'POST', u'/domains/123123/records/A', data={ u'roundRobin': [{ u'value': '1.2.3.4' }, { u'value': '1.2.3.5' }], u'name': '', u'ttl': 300 }), call(u'POST', u'/domains/123123/records/A', data={ u'roundRobin': [{ u'value': '2.2.3.6' }], u'name': u'www', u'ttl': 300 }), call(u'POST', u'/domains/123123/records/ANAME', data={ u'roundRobin': [{ u'disableFlag': False, u'value': u'aname.unit.tests.' }], u'name': u'', u'ttl': 1800 }), call(u'POST', u'/domains/123123/records/CNAME', data={ u'host': 'unit.tests.', u'name': u'included', u'ttl': 3600 }), call(u'POST', u'/domains/123123/records/AAAA', data={ u'roundRobin': [{ u'value': '2601:644:500:e210:62f8:1dff:feb8:947a' }], u'name': u'aaaa', u'ttl': 600 }), call(u'POST', u'/domains/123123/records/MX', data={ u'roundRobin': [{ u'value': 'smtp-4.unit.tests.', u'level': 10 }, { u'value': 'smtp-2.unit.tests.', u'level': 20 }, { u'value': 'smtp-3.unit.tests.', u'level': 30 }, { u'value': 'smtp-1.unit.tests.', u'level': 40 }], u'name': u'mx', u'value': 'smtp-1.unit.tests.', u'ttl': 300 }), call(u'POST', u'/domains/123123/records/CAA', data={ u'roundRobin': [{ u'flag': 0, u'tag': 'issue', u'data': 'ca.unit.tests' }], u'name': '', u'ttl': 3600 }), call(u'POST', u'/domains/123123/records/PTR', data={ u'host': 'foo.bar.com.', u'name': u'ptr', u'ttl': 300 }), call(u'POST', u'/domains/123123/records/A', data={ u'roundRobin': [{ u'value': '2.2.3.6' }], u'name': u'www.sub', u'ttl': 300 }), call(u'POST', u'/domains/123123/records/NAPTR', data={ u'roundRobin': [{ u'service': 'SIP+D2U', u'regularExpression': '!^.*$!sip:[email protected]!', u'flags': 'S', u'preference': 100, u'order': 10, u'replacement': '.' }, { u'service': 'SIP+D2U', u'regularExpression': '!^.*$!sip:[email protected]!', u'flags': 'U', u'preference': 100, u'order': 100, u'replacement': '.' }], u'name': u'naptr', u'ttl': 600 }), call(u'POST', u'/domains/123123/records/SPF', data={ u'roundRobin': [{ u'value': u'"v=spf1 ip4:192.168.0.1/16-all"' }], u'name': u'spf', u'ttl': 600 }) ]) self.assertEquals(20, provider._client._request.call_count) provider._client._request.reset_mock() # delete 1 and update 1 provider._client.records = Mock( return_value=[{ 'id': 11189897, 'name': 'www', 'value': '1.2.3.4', 'ttl': 300, 'type': 'A', }, { 'id': 11189898, 'name': 'www', 'value': '2.2.3.4', 'ttl': 300, 'type': 'A', }, { 'id': 11189899, 'name': 'ttl', 'value': '3.2.3.4', 'ttl': 600, 'value': ['3.2.3.4'] }, { 'id': 11189899, 'type': 'ALIAS', 'name': 'alias', 'ttl': 600, 'value': [{ 'value': 'aname.unit.tests.' }] }]) # Domain exists, we don't care about return # resp.json.side_effect = ['{}'] provider._client.domains = Mock(return_value={"unit.tests.": 123123}) resp = Mock() resp.json = Mock( return_value={ "id": 123123, "name": "unit.tests.", "soa": { "primaryNameserver": "ns11.constellix.com.", "email": "dns.constellix.com.", "ttl": 86400, "serial": 2015010118, "refresh": 43200, "retry": 3600, "expire": 1209600, "negCache": 180 }, "createdTs": "2019-08-02T12:52:10Z", "modifiedTs": "2019-08-13T11:45:59Z", "typeId": 1, "domainTags": [], "hasGtdRegions": False, "hasGeoIP": False, "nameserverGroup": 1, "nameservers": [ "ns11.constellix.com.", "ns21.constellix.com.", "ns31.constellix.com.", "ns41.constellix.net.", "ns51.constellix.net.", "ns61.constellix.net." ], "note": "", "status": "ACTIVE", }) provider._client._request = Mock(return_value=resp) wanted = Zone('unit.tests.', []) wanted.add_record( Record.new(wanted, 'ttl', { 'ttl': 300, 'type': 'A', 'value': '3.2.3.4' })) plan = provider.plan(wanted) self.assertEquals(3, len(plan.changes)) self.assertEquals(3, provider.apply(plan)) # recreate for update, and deletes for the 2 parts of the other provider._client._request.assert_has_calls([ call(u'GET', u'/domains/123123'), call(u'DELETE', u'/domains/123123/records/A/11189899'), call(u'POST', u'/domains/123123/records/A', data={ u'roundRobin': [{ u'value': u'3.2.3.4' }], u'name': u'ttl', u'ttl': 300 }), call('DELETE', '/123123/records/A/11189897'), call('DELETE', '/123123/records/A/11189898'), call('DELETE', '/123123/records/ANAME/11189899') ], any_order=True)
def test_provider(self): source = YamlProvider('test', join(dirname(__file__), 'config')) zone = Zone('unit.tests.', []) dynamic_zone = Zone('dynamic.tests.', []) # With target we don't add anything source.populate(zone, target=source) self.assertEqual(0, len(zone.records)) # without it we see everything source.populate(zone) self.assertEqual(25, len(zone.records)) source.populate(dynamic_zone) self.assertEqual(6, len(dynamic_zone.records)) # Assumption here is that a clean round-trip means that everything # worked as expected, data that went in came back out and could be # pulled in yet again and still match up. That assumes that the input # data completely exercises things. This assumption can be tested by # relatively well by running # ./script/coverage tests/test_octodns_provider_yaml.py and # looking at the coverage file # ./htmlcov/octodns_provider_yaml_py.html with TemporaryDirectory() as td: # Add some subdirs to make sure that it can create them directory = join(td.dirname, 'sub', 'dir') yaml_file = join(directory, 'unit.tests.yaml') dynamic_yaml_file = join(directory, 'dynamic.tests.yaml') target = YamlProvider('test', directory, supports_root_ns=False) # We add everything plan = target.plan(zone) self.assertEqual( 22, len([c for c in plan.changes if isinstance(c, Create)])) self.assertFalse(isfile(yaml_file)) # Now actually do it self.assertEqual(22, target.apply(plan)) self.assertTrue(isfile(yaml_file)) # Dynamic plan plan = target.plan(dynamic_zone) self.assertEqual( 6, len([c for c in plan.changes if isinstance(c, Create)])) self.assertFalse(isfile(dynamic_yaml_file)) # Apply it self.assertEqual(6, target.apply(plan)) self.assertTrue(isfile(dynamic_yaml_file)) # There should be no changes after the round trip reloaded = Zone('unit.tests.', []) target.populate(reloaded) self.assertDictEqual( {'included': ['test']}, [x for x in reloaded.records if x.name == 'included'][0]._octodns, ) # manually copy over the root since it will have been ignored # when things were written out reloaded.add_record(zone.root_ns) self.assertFalse(zone.changes(reloaded, target=source)) # A 2nd sync should still create everything plan = target.plan(zone) self.assertEqual( 22, len([c for c in plan.changes if isinstance(c, Create)])) with open(yaml_file) as fh: data = safe_load(fh.read()) # '' has some of both roots = sorted(data.pop(''), key=lambda r: r['type']) self.assertTrue('values' in roots[0]) # A self.assertTrue('geo' in roots[0]) # geo made the trip self.assertTrue('value' in roots[1]) # CAA self.assertTrue('values' in roots[2]) # SSHFP # these are stored as plural 'values' self.assertTrue('values' in data.pop('_srv._tcp')) self.assertTrue('values' in data.pop('mx')) self.assertTrue('values' in data.pop('naptr')) self.assertTrue('values' in data.pop('sub')) self.assertTrue('values' in data.pop('txt')) self.assertTrue('values' in data.pop('loc')) self.assertTrue('values' in data.pop('urlfwd')) self.assertTrue('values' in data.pop('sub.txt')) self.assertTrue('values' in data.pop('subzone')) # these are stored as singular 'value' self.assertTrue('value' in data.pop('_imap._tcp')) self.assertTrue('value' in data.pop('_pop3._tcp')) self.assertTrue('value' in data.pop('aaaa')) self.assertTrue('value' in data.pop('cname')) self.assertTrue('value' in data.pop('dname')) self.assertTrue('value' in data.pop('included')) self.assertTrue('value' in data.pop('ptr')) self.assertTrue('value' in data.pop('spf')) self.assertTrue('value' in data.pop('www')) self.assertTrue('value' in data.pop('www.sub')) # make sure nothing is left self.assertEqual([], list(data.keys())) with open(dynamic_yaml_file) as fh: data = safe_load(fh.read()) # make sure new dynamic records made the trip dyna = data.pop('a') self.assertTrue('values' in dyna) # self.assertTrue('dynamic' in dyna) # TODO: # make sure new dynamic records made the trip dyna = data.pop('aaaa') self.assertTrue('values' in dyna) # self.assertTrue('dynamic' in dyna) dyna = data.pop('cname') self.assertTrue('value' in dyna) # self.assertTrue('dynamic' in dyna) dyna = data.pop('real-ish-a') self.assertTrue('values' in dyna) # self.assertTrue('dynamic' in dyna) dyna = data.pop('simple-weighted') self.assertTrue('value' in dyna) # self.assertTrue('dynamic' in dyna) dyna = data.pop('pool-only-in-fallback') self.assertTrue('value' in dyna) # self.assertTrue('dynamic' in dyna) # make sure nothing is left self.assertEqual([], list(data.keys()))
class TestUltraProvider(TestCase): expected = Zone('unit.tests.', []) host = 'https://restapi.ultradns.com' empty_body = [{"errorCode": 70002, "errorMessage": "Data not found."}] expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) def test_login(self): path = '/v2/authorization/token' # Bad Auth with requests_mock() as mock: mock.post('{}{}'.format(self.host, path), status_code=401, text='{"errorCode": 60001}') with self.assertRaises(Exception) as ctx: UltraProvider('test', 'account', 'user', 'wrongpass') self.assertEquals('Unauthorized', text_type(ctx.exception)) # Good Auth with requests_mock() as mock: headers = {'Content-Type': 'application/x-www-form-urlencoded'} mock.post('{}{}'.format(self.host, path), status_code=200, request_headers=headers, text='{"token type": "Bearer", "refresh_token": "abc", ' '"access_token":"123", "expires_in": "3600"}') UltraProvider('test', 'account', 'user', 'rightpass') self.assertEquals(1, mock.call_count) expected_payload = "grant_type=password&username=user&"\ "password=rightpass" self.assertEquals(mock.last_request.text, expected_payload) def test_get_zones(self): provider = _get_provider() path = "/v2/zones" # Test authorization issue with requests_mock() as mock: mock.get('{}{}'.format(self.host, path), status_code=400, json={ "errorCode": 60004, "errorMessage": "Authorization Header required" }) with self.assertRaises(HTTPError) as ctx: zones = provider.zones self.assertEquals(400, ctx.exception.response.status_code) # Test no zones exist error with requests_mock() as mock: mock.get('{}{}'.format(self.host, path), status_code=404, headers={'Authorization': 'Bearer 123'}, json=self.empty_body) zones = provider.zones self.assertEquals(1, mock.call_count) self.assertEquals(list(), zones) # Reset zone cache so they are queried again provider._zones = None with requests_mock() as mock: payload = { "resultInfo": { "totalCount": 1, "offset": 0, "returnedCount": 1 }, "zones": [{ "properties": { "name": "testzone123.com.", "accountName": "testaccount", "type": "PRIMARY", "dnssecStatus": "UNSIGNED", "status": "ACTIVE", "owner": "user", "resourceRecordCount": 5, "lastModifiedDateTime": "2020-06-19T00:47Z" } }] } mock.get('{}{}'.format(self.host, path), status_code=200, headers={'Authorization': 'Bearer 123'}, json=payload) zones = provider.zones self.assertEquals(1, mock.call_count) self.assertEquals(1, len(zones)) self.assertEquals('testzone123.com.', zones[0]) # Test different paging behavior provider._zones = None with requests_mock() as mock: mock.get('{}{}?limit=100&q=zone_type%3APRIMARY&offset=0'.format( self.host, path), status_code=200, json={ "resultInfo": { "totalCount": 15, "offset": 0, "returnedCount": 10 }, "zones": [] }) mock.get('{}{}?limit=100&q=zone_type%3APRIMARY&offset=10'.format( self.host, path), status_code=200, json={ "resultInfo": { "totalCount": 15, "offset": 10, "returnedCount": 5 }, "zones": [] }) zones = provider.zones self.assertEquals(2, mock.call_count) def test_request(self): provider = _get_provider() path = '/foo' payload = {'a': 1} with requests_mock() as mock: mock.get('{}{}'.format(self.host, path), status_code=401, headers={'Authorization': 'Bearer 123'}, json={}) with self.assertRaises(Exception) as ctx: provider._get(path) self.assertEquals('Unauthorized', text_type(ctx.exception)) # Test all GET patterns with requests_mock() as mock: mock.get('{}{}'.format(self.host, path), status_code=200, headers={'Authorization': 'Bearer 123'}, json=payload) provider._get(path, json=payload) mock.get('{}{}?a=1'.format(self.host, path), status_code=200, headers={'Authorization': 'Bearer 123'}) provider._get(path, params=payload, json_response=False) # Test all POST patterns with requests_mock() as mock: mock.post('{}{}'.format(self.host, path), status_code=200, headers={'Authorization': 'Bearer 123'}, json=payload) provider._post(path, json=payload) mock.post('{}{}'.format(self.host, path), status_code=200, headers={'Authorization': 'Bearer 123'}, text="{'a':1}") provider._post(path, data=payload, json_response=False) # Test all PUT patterns with requests_mock() as mock: mock.put('{}{}'.format(self.host, path), status_code=200, headers={'Authorization': 'Bearer 123'}, json=payload) provider._put(path, json=payload) # Test all DELETE patterns with requests_mock() as mock: mock.delete('{}{}'.format(self.host, path), status_code=200, headers={'Authorization': 'Bearer 123'}) provider._delete(path, json_response=False) def test_zone_records(self): provider = _get_provider() zone_payload = { "resultInfo": { "totalCount": 1, "offset": 0, "returnedCount": 1 }, "zones": [{ "properties": { "name": "octodns1.test." } }] } records_payload = { "zoneName": "octodns1.test.", "rrSets": [ { "ownerName": "octodns1.test.", "rrtype": "NS (2)", "ttl": 86400, "rdata": ["ns1.octodns1.test."] }, { "ownerName": "octodns1.test.", "rrtype": "SOA (6)", "ttl": 86400, "rdata": ["pdns1.ultradns.com. phelps.netflix.com. 1 10 10 10 10"] }, ], "resultInfo": { "totalCount": 2, "offset": 0, "returnedCount": 2 } } zone_path = '/v2/zones' rec_path = '/v2/zones/octodns1.test./rrsets' with requests_mock() as mock: mock.get('{}{}?limit=100&q=zone_type%3APRIMARY&offset=0'.format( self.host, zone_path), status_code=200, json=zone_payload) mock.get('{}{}?offset=0&limit=100'.format(self.host, rec_path), status_code=200, json=records_payload) zone = Zone('octodns1.test.', []) self.assertTrue(provider.zone_records(zone)) self.assertEquals(mock.call_count, 2) # Populate the same zone again and confirm cache is hit self.assertTrue(provider.zone_records(zone)) self.assertEquals(mock.call_count, 2) def test_populate(self): provider = _get_provider() # Non-existent zone doesn't populate anything with requests_mock() as mock: mock.get(ANY, status_code=404, json=self.empty_body) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(set(), zone.records) # re-populating the same non-existent zone uses cache and makes no # calls again = Zone('unit.tests.', []) provider.populate(again) self.assertEquals(set(), again.records) # Test zones with data provider._zones = None path = '/v2/zones' with requests_mock() as mock: with open('tests/fixtures/ultra-zones-page-1.json') as fh: mock.get( '{}{}?limit=100&q=zone_type%3APRIMARY&offset=0'.format( self.host, path), status_code=200, text=fh.read()) with open('tests/fixtures/ultra-zones-page-2.json') as fh: mock.get( '{}{}?limit=100&q=zone_type%3APRIMARY&offset=10'.format( self.host, path), status_code=200, text=fh.read()) with open('tests/fixtures/ultra-records-page-1.json') as fh: rec_path = '/v2/zones/octodns1.test./rrsets' mock.get('{}{}?offset=0&limit=100'.format(self.host, rec_path), status_code=200, text=fh.read()) with open('tests/fixtures/ultra-records-page-2.json') as fh: rec_path = '/v2/zones/octodns1.test./rrsets' mock.get('{}{}?offset=10&limit=100'.format( self.host, rec_path), status_code=200, text=fh.read()) zone = Zone('octodns1.test.', []) self.assertTrue(provider.populate(zone)) self.assertEquals('octodns1.test.', zone.name) self.assertEquals(12, len(zone.records)) self.assertEquals(4, mock.call_count) def test_apply(self): provider = _get_provider() provider._request = Mock() provider._request.side_effect = [ UltraNoZonesExistException('No Zones'), None, # zone create ] + [None] * 15 # individual record creates # non-existent zone, create everything plan = provider.plan(self.expected) self.assertEquals(15, len(plan.changes)) self.assertEquals(15, provider.apply(plan)) self.assertFalse(plan.exists) provider._request.assert_has_calls( [ # created the domain call('POST', '/v2/zones', json={ 'properties': { 'name': 'unit.tests.', 'accountName': 'testacct', 'type': 'PRIMARY' }, 'primaryCreateInfo': { 'createType': 'NEW' } }), # Validate multi-ip apex A record is correct call('POST', '/v2/zones/unit.tests./rrsets/A/unit.tests.', json={ 'ttl': 300, 'rdata': ['1.2.3.4', '1.2.3.5'], 'profile': { '@context': 'http://schemas.ultradns.com/RDPool.jsonschema', 'order': 'FIXED', 'description': 'unit.tests.' } }), # make sure semicolons are not escaped when sending data call('POST', '/v2/zones/unit.tests./rrsets/TXT/txt.unit.tests.', json={ 'ttl': 600, 'rdata': [ 'Bah bah black sheep', 'have you any wool.', 'v=DKIM1;k=rsa;s=email;h=sha256;' 'p=A/kinda+of/long/string+with+numb3rs' ] }), ], True) # expected number of total calls self.assertEquals(17, provider._request.call_count) # Create sample rrset payload to attempt to alter page1 = json_load(open('tests/fixtures/ultra-records-page-1.json')) page2 = json_load(open('tests/fixtures/ultra-records-page-2.json')) mock_rrsets = list() mock_rrsets.extend(page1['rrSets']) mock_rrsets.extend(page2['rrSets']) # Seed a bunch of records into a zone and verify update / delete ops provider._request.reset_mock() provider._zones = ['octodns1.test.'] provider.zone_records = Mock(return_value=mock_rrsets) provider._request.side_effect = [None] * 13 wanted = Zone('octodns1.test.', []) wanted.add_record( Record.new( wanted, '', { 'ttl': 60, # Change TTL 'type': 'A', 'value': '5.6.7.8' # Change number of IPs (3 -> 1) })) wanted.add_record( Record.new( wanted, 'txt', { 'ttl': 3600, 'type': 'TXT', 'values': [ # Alter TXT value "foobar", "v=spf1 include:mail.server.net ?all" ] })) plan = provider.plan(wanted) self.assertEquals(11, len(plan.changes)) self.assertEquals(11, provider.apply(plan)) self.assertTrue(plan.exists) provider._request.assert_has_calls( [ # Validate multi-ip apex A record replaced with standard A call('PUT', '/v2/zones/octodns1.test./rrsets/A/octodns1.test.', json={ 'ttl': 60, 'rdata': ['5.6.7.8'] }), # Make sure TXT value is properly updated call('PUT', '/v2/zones/octodns1.test./rrsets/TXT/txt.octodns1.test.', json={ 'ttl': 3600, 'rdata': ["foobar", "v=spf1 include:mail.server.net ?all"] }), # Confirm a few of the DELETE operations properly occur call('DELETE', '/v2/zones/octodns1.test./rrsets/A/a.octodns1.test.', json_response=False), call( 'DELETE', '/v2/zones/octodns1.test./rrsets/AAAA/aaaa.octodns1.test.', json_response=False), call('DELETE', '/v2/zones/octodns1.test./rrsets/CAA/caa.octodns1.test.', json_response=False), call( 'DELETE', '/v2/zones/octodns1.test./rrsets/CNAME/cname.octodns1.test.', json_response=False), ], True) def test_gen_data(self): provider = _get_provider() zone = Zone('unit.tests.', []) for name, _type, expected_path, expected_payload, expected_record in ( # A ('a', 'A', '/v2/zones/unit.tests./rrsets/A/a.unit.tests.', { 'ttl': 60, 'rdata': ['1.2.3.4'] }, Record.new(zone, 'a', { 'ttl': 60, 'type': 'A', 'values': ['1.2.3.4'] })), ('a', 'A', '/v2/zones/unit.tests./rrsets/A/a.unit.tests.', { 'ttl': 60, 'rdata': ['1.2.3.4', '5.6.7.8'], 'profile': { '@context': 'http://schemas.ultradns.com/RDPool.jsonschema', 'order': 'FIXED', 'description': 'a.unit.tests.' } }, Record.new(zone, 'a', { 'ttl': 60, 'type': 'A', 'values': ['1.2.3.4', '5.6.7.8'] })), # AAAA ('aaaa', 'AAAA', '/v2/zones/unit.tests./rrsets/AAAA/aaaa.unit.tests.', { 'ttl': 60, 'rdata': ['::1'] }, Record.new(zone, 'aaaa', { 'ttl': 60, 'type': 'AAAA', 'values': ['::1'] })), ('aaaa', 'AAAA', '/v2/zones/unit.tests./rrsets/AAAA/aaaa.unit.tests.', { 'ttl': 60, 'rdata': ['::1', '::2'], 'profile': { '@context': 'http://schemas.ultradns.com/RDPool.jsonschema', 'order': 'FIXED', 'description': 'aaaa.unit.tests.' } }, Record.new(zone, 'aaaa', { 'ttl': 60, 'type': 'AAAA', 'values': ['::1', '::2'] })), # CAA ('caa', 'CAA', '/v2/zones/unit.tests./rrsets/CAA/caa.unit.tests.', { 'ttl': 60, 'rdata': ['0 issue foo.com'] }, Record.new( zone, 'caa', { 'ttl': 60, 'type': 'CAA', 'values': [{ 'flags': 0, 'tag': 'issue', 'value': 'foo.com' }] })), # CNAME ('cname', 'CNAME', '/v2/zones/unit.tests./rrsets/CNAME/cname.unit.tests.', { 'ttl': 60, 'rdata': ['netflix.com.'] }, Record.new(zone, 'cname', { 'ttl': 60, 'type': 'CNAME', 'value': 'netflix.com.' })), # MX ('mx', 'MX', '/v2/zones/unit.tests./rrsets/MX/mx.unit.tests.', { 'ttl': 60, 'rdata': ['1 mx1.unit.tests.', '1 mx2.unit.tests.'] }, Record.new( zone, 'mx', { 'ttl': 60, 'type': 'MX', 'values': [{ 'preference': 1, 'exchange': 'mx1.unit.tests.' }, { 'preference': 1, 'exchange': 'mx2.unit.tests.' }] })), # NS ('ns', 'NS', '/v2/zones/unit.tests./rrsets/NS/ns.unit.tests.', { 'ttl': 60, 'rdata': ['ns1.unit.tests.', 'ns2.unit.tests.'] }, Record.new( zone, 'ns', { 'ttl': 60, 'type': 'NS', 'values': ['ns1.unit.tests.', 'ns2.unit.tests.'] })), # PTR ('ptr', 'PTR', '/v2/zones/unit.tests./rrsets/PTR/ptr.unit.tests.', { 'ttl': 60, 'rdata': ['a.unit.tests.'] }, Record.new(zone, 'ptr', { 'ttl': 60, 'type': 'PTR', 'value': 'a.unit.tests.' })), # SPF ('spf', 'SPF', '/v2/zones/unit.tests./rrsets/SPF/spf.unit.tests.', { 'ttl': 60, 'rdata': ['v=spf1 -all'] }, Record.new(zone, 'spf', { 'ttl': 60, 'type': 'SPF', 'values': ['v=spf1 -all'] })), # SRV ('_srv._tcp', 'SRV', '/v2/zones/unit.tests./rrsets/SRV/_srv._tcp.unit.tests.', { 'ttl': 60, 'rdata': ['10 20 443 target.unit.tests.'] }, Record.new( zone, '_srv._tcp', { 'ttl': 60, 'type': 'SRV', 'values': [{ 'priority': 10, 'weight': 20, 'port': 443, 'target': 'target.unit.tests.' }] })), # TXT ('txt', 'TXT', '/v2/zones/unit.tests./rrsets/TXT/txt.unit.tests.', { 'ttl': 60, 'rdata': ['abc', 'def'] }, Record.new(zone, 'txt', { 'ttl': 60, 'type': 'TXT', 'values': ['abc', 'def'] })), # ALIAS ('', 'ALIAS', '/v2/zones/unit.tests./rrsets/APEXALIAS/unit.tests.', { 'ttl': 60, 'rdata': ['target.unit.tests.'] }, Record.new(zone, '', { 'ttl': 60, 'type': 'ALIAS', 'value': 'target.unit.tests.' })), ): # Validate path and payload based on record meet expectations path, payload = provider._gen_data(expected_record) self.assertEqual(expected_path, path) self.assertEqual(expected_payload, payload) # Use generator for record and confirm the output matches rec = provider._record_for(zone, name, _type, expected_payload, False) path, payload = provider._gen_data(rec) self.assertEqual(expected_path, path) self.assertEqual(expected_payload, payload)
class TestCloudflareProvider(TestCase): expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) # Our test suite differs a bit, add our NS and remove the simple one expected.add_record(Record.new(expected, 'under', { 'ttl': 3600, 'type': 'NS', 'values': [ 'ns1.unit.tests.', 'ns2.unit.tests.', ] })) for record in list(expected.records): if record.name == 'sub' and record._type == 'NS': expected._remove_record(record) break empty = {'result': [], 'result_info': {'count': 0, 'per_page': 0}} def test_populate(self): provider = CloudflareProvider('test', 'email', 'token', retry_period=0) # Bad requests with requests_mock() as mock: mock.get(ANY, status_code=400, text='{"success":false,"errors":[{"code":1101,' '"message":"request was invalid"}],' '"messages":[],"result":null}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('CloudflareError', type(ctx.exception).__name__) self.assertEquals('request was invalid', text_type(ctx.exception)) # Bad auth with requests_mock() as mock: mock.get(ANY, status_code=403, text='{"success":false,"errors":[{"code":9103,' '"message":"Unknown X-Auth-Key or X-Auth-Email"}],' '"messages":[],"result":null}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('CloudflareAuthenticationError', type(ctx.exception).__name__) self.assertEquals('Unknown X-Auth-Key or X-Auth-Email', text_type(ctx.exception)) # Bad auth, unknown resp with requests_mock() as mock: mock.get(ANY, status_code=403, text='{}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('CloudflareAuthenticationError', type(ctx.exception).__name__) self.assertEquals('Cloudflare error', text_type(ctx.exception)) # General error with requests_mock() as mock: mock.get(ANY, status_code=502, text='Things caught fire') with self.assertRaises(HTTPError) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(502, ctx.exception.response.status_code) # Rate Limit error with requests_mock() as mock: mock.get(ANY, status_code=429, text='{"success":false,"errors":[{"code":10100,' '"message":"More than 1200 requests per 300 seconds ' 'reached. Please wait and consider throttling your ' 'request speed"}],"messages":[],"result":null}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('CloudflareRateLimitError', type(ctx.exception).__name__) self.assertEquals('More than 1200 requests per 300 seconds ' 'reached. Please wait and consider throttling ' 'your request speed', text_type(ctx.exception)) # Rate Limit error, unknown resp with requests_mock() as mock: mock.get(ANY, status_code=429, text='{}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('CloudflareRateLimitError', type(ctx.exception).__name__) self.assertEquals('Cloudflare error', text_type(ctx.exception)) # Non-existent zone doesn't populate anything with requests_mock() as mock: mock.get(ANY, status_code=200, json=self.empty) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(set(), zone.records) # re-populating the same non-existent zone uses cache and makes no # calls again = Zone('unit.tests.', []) provider.populate(again) self.assertEquals(set(), again.records) # bust zone cache provider._zones = None # existing zone with data with requests_mock() as mock: base = 'https://api.cloudflare.com/client/v4/zones' # zones with open('tests/fixtures/cloudflare-zones-page-1.json') as fh: mock.get('{}?page=1'.format(base), status_code=200, text=fh.read()) with open('tests/fixtures/cloudflare-zones-page-2.json') as fh: mock.get('{}?page=2'.format(base), status_code=200, text=fh.read()) mock.get('{}?page=3'.format(base), status_code=200, json={'result': [], 'result_info': {'count': 0, 'per_page': 0}}) # records base = '{}/234234243423aaabb334342aaa343435/dns_records' \ .format(base) with open('tests/fixtures/cloudflare-dns_records-' 'page-1.json') as fh: mock.get('{}?page=1'.format(base), status_code=200, text=fh.read()) with open('tests/fixtures/cloudflare-dns_records-' 'page-2.json') as fh: mock.get('{}?page=2'.format(base), status_code=200, text=fh.read()) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(13, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEquals(0, len(changes)) # re-populating the same zone/records comes out of cache, no calls again = Zone('unit.tests.', []) provider.populate(again) self.assertEquals(13, len(again.records)) def test_apply(self): provider = CloudflareProvider('test', 'email', 'token', retry_period=0) provider._request = Mock() provider._request.side_effect = [ self.empty, # no zones { 'result': { 'id': 42, } }, # zone create ] + [None] * 22 # individual record creates # non-existent zone, create everything plan = provider.plan(self.expected) self.assertEquals(13, len(plan.changes)) self.assertEquals(13, provider.apply(plan)) self.assertFalse(plan.exists) provider._request.assert_has_calls([ # created the domain call('POST', '/zones', data={ 'jump_start': False, 'name': 'unit.tests' }), # created at least one of the record with expected data call('POST', '/zones/42/dns_records', data={ 'content': 'ns1.unit.tests.', 'type': 'NS', 'name': 'under.unit.tests', 'ttl': 3600 }), # make sure semicolons are not escaped when sending data call('POST', '/zones/42/dns_records', data={ 'content': 'v=DKIM1;k=rsa;s=email;h=sha256;' 'p=A/kinda+of/long/string+with+numb3rs', 'type': 'TXT', 'name': 'txt.unit.tests', 'ttl': 600 }), ], True) # expected number of total calls self.assertEquals(23, provider._request.call_count) provider._request.reset_mock() provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997653", "type": "A", "name": "www.unit.tests", "content": "1.2.3.4", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997654", "type": "A", "name": "www.unit.tests", "content": "2.2.3.4", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:44.030044Z", "created_on": "2017-03-11T18:01:44.030044Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997655", "type": "A", "name": "nc.unit.tests", "content": "3.2.3.4", "proxiable": True, "proxied": False, "ttl": 120, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:44.030044Z", "created_on": "2017-03-11T18:01:44.030044Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997655", "type": "A", "name": "ttl.unit.tests", "content": "4.2.3.4", "proxiable": True, "proxied": False, "ttl": 600, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:44.030044Z", "created_on": "2017-03-11T18:01:44.030044Z", "meta": { "auto_added": False } }, ]) # we don't care about the POST/create return values provider._request.return_value = {} # Test out the create rate-limit handling, then 9 successes provider._request.side_effect = [ CloudflareRateLimitError('{}'), ] + ([None] * 3) wanted = Zone('unit.tests.', []) wanted.add_record(Record.new(wanted, 'nc', { 'ttl': 60, # TTL is below their min 'type': 'A', 'value': '3.2.3.4' })) wanted.add_record(Record.new(wanted, 'ttl', { 'ttl': 300, # TTL change 'type': 'A', 'value': '3.2.3.4' })) plan = provider.plan(wanted) # only see the delete & ttl update, below min-ttl is filtered out self.assertEquals(2, len(plan.changes)) self.assertEquals(2, provider.apply(plan)) self.assertTrue(plan.exists) # creates a the new value and then deletes all the old provider._request.assert_has_calls([ call('PUT', '/zones/42/dns_records/' 'fc12ab34cd5611334422ab3322997655', data={ 'content': '3.2.3.4', 'type': 'A', 'name': 'ttl.unit.tests', 'proxied': False, 'ttl': 300 }), call('DELETE', '/zones/ff12ab34cd5611334422ab3322997650/' 'dns_records/fc12ab34cd5611334422ab3322997653'), call('DELETE', '/zones/ff12ab34cd5611334422ab3322997650/' 'dns_records/fc12ab34cd5611334422ab3322997654') ]) def test_update_add_swap(self): provider = CloudflareProvider('test', 'email', 'token', retry_period=0) provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997653", "type": "A", "name": "a.unit.tests", "content": "1.1.1.1", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997654", "type": "A", "name": "a.unit.tests", "content": "2.2.2.2", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, ]) provider._request = Mock() provider._request.side_effect = [ CloudflareRateLimitError('{}'), self.empty, # no zones { 'result': { 'id': 42, } }, # zone create None, None, None, None, ] # Add something and delete something zone = Zone('unit.tests.', []) existing = Record.new(zone, 'a', { 'ttl': 300, 'type': 'A', # This matches the zone data above, one to swap, one to leave 'values': ['1.1.1.1', '2.2.2.2'], }) new = Record.new(zone, 'a', { 'ttl': 300, 'type': 'A', # This leaves one, swaps ones, and adds one 'values': ['2.2.2.2', '3.3.3.3', '4.4.4.4'], }) change = Update(existing, new) plan = Plan(zone, zone, [change], True) provider._apply(plan) # get the list of zones, create a zone, add some records, update # something, and delete something provider._request.assert_has_calls([ call('GET', '/zones', params={'page': 1}), call('POST', '/zones', data={ 'jump_start': False, 'name': 'unit.tests' }), call('POST', '/zones/42/dns_records', data={ 'content': '4.4.4.4', 'type': 'A', 'name': 'a.unit.tests', 'proxied': False, 'ttl': 300 }), call('PUT', '/zones/42/dns_records/' 'fc12ab34cd5611334422ab3322997654', data={ 'content': '2.2.2.2', 'type': 'A', 'name': 'a.unit.tests', 'proxied': False, 'ttl': 300 }), call('PUT', '/zones/42/dns_records/' 'fc12ab34cd5611334422ab3322997653', data={ 'content': '3.3.3.3', 'type': 'A', 'name': 'a.unit.tests', 'proxied': False, 'ttl': 300 }), ]) def test_update_delete(self): # We need another run so that we can delete, we can't both add and # delete in one go b/c of swaps provider = CloudflareProvider('test', 'email', 'token', retry_period=0) provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997653", "type": "NS", "name": "unit.tests", "content": "ns1.foo.bar", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997654", "type": "NS", "name": "unit.tests", "content": "ns2.foo.bar", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, ]) provider._request = Mock() provider._request.side_effect = [ CloudflareRateLimitError('{}'), self.empty, # no zones { 'result': { 'id': 42, } }, # zone create None, None, ] # Add something and delete something zone = Zone('unit.tests.', []) existing = Record.new(zone, '', { 'ttl': 300, 'type': 'NS', # This matches the zone data above, one to delete, one to leave 'values': ['ns1.foo.bar.', 'ns2.foo.bar.'], }) new = Record.new(zone, '', { 'ttl': 300, 'type': 'NS', # This leaves one and deletes one 'value': 'ns2.foo.bar.', }) change = Update(existing, new) plan = Plan(zone, zone, [change], True) provider._apply(plan) # Get zones, create zone, create a record, delete a record provider._request.assert_has_calls([ call('GET', '/zones', params={'page': 1}), call('POST', '/zones', data={ 'jump_start': False, 'name': 'unit.tests' }), call('PUT', '/zones/42/dns_records/' 'fc12ab34cd5611334422ab3322997654', data={ 'content': 'ns2.foo.bar.', 'type': 'NS', 'name': 'unit.tests', 'ttl': 300 }), call('DELETE', '/zones/42/dns_records/' 'fc12ab34cd5611334422ab3322997653') ]) def test_ptr(self): provider = CloudflareProvider('test', 'email', 'token') zone = Zone('unit.tests.', []) # PTR record ptr_record = Record.new(zone, 'ptr', { 'ttl': 300, 'type': 'PTR', 'value': 'foo.bar.com.' }) ptr_record_contents = provider._gen_data(ptr_record) self.assertEquals({ 'name': 'ptr.unit.tests', 'ttl': 300, 'type': 'PTR', 'content': 'foo.bar.com.' }, list(ptr_record_contents)[0]) def test_srv(self): provider = CloudflareProvider('test', 'email', 'token') zone = Zone('unit.tests.', []) # SRV record not under a sub-domain srv_record = Record.new(zone, '_example._tcp', { 'ttl': 300, 'type': 'SRV', 'value': { 'port': 1234, 'priority': 0, 'target': 'nc.unit.tests.', 'weight': 5 } }) # SRV record under a sub-domain srv_record_with_sub = Record.new(zone, '_example._tcp.sub', { 'ttl': 300, 'type': 'SRV', 'value': { 'port': 1234, 'priority': 0, 'target': 'nc.unit.tests.', 'weight': 5 } }) srv_record_contents = provider._gen_data(srv_record) srv_record_with_sub_contents = provider._gen_data(srv_record_with_sub) self.assertEquals({ 'name': '_example._tcp.unit.tests', 'ttl': 300, 'type': 'SRV', 'data': { 'service': '_example', 'proto': '_tcp', 'name': 'unit.tests.', 'priority': 0, 'weight': 5, 'port': 1234, 'target': 'nc.unit.tests' } }, list(srv_record_contents)[0]) self.assertEquals({ 'name': '_example._tcp.sub.unit.tests', 'ttl': 300, 'type': 'SRV', 'data': { 'service': '_example', 'proto': '_tcp', 'name': 'sub', 'priority': 0, 'weight': 5, 'port': 1234, 'target': 'nc.unit.tests' } }, list(srv_record_with_sub_contents)[0]) def test_alias(self): provider = CloudflareProvider('test', 'email', 'token') # A CNAME for us to transform to ALIAS provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997642", "type": "CNAME", "name": "unit.tests", "content": "www.unit.tests", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, ]) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(1, len(zone.records)) record = list(zone.records)[0] self.assertEquals('', record.name) self.assertEquals('unit.tests.', record.fqdn) self.assertEquals('ALIAS', record._type) self.assertEquals('www.unit.tests.', record.value) # Make sure we transform back to CNAME going the other way contents = provider._gen_data(record) self.assertEquals({ 'content': 'www.unit.tests.', 'name': 'unit.tests', 'proxied': False, 'ttl': 300, 'type': 'CNAME' }, list(contents)[0]) def test_gen_key(self): provider = CloudflareProvider('test', 'email', 'token') for expected, data in ( ('foo.bar.com.', { 'content': 'foo.bar.com.', 'type': 'CNAME', }), ('10 foo.bar.com.', { 'content': 'foo.bar.com.', 'priority': 10, 'type': 'MX', }), ('0 tag some-value', { 'data': { 'flags': 0, 'tag': 'tag', 'value': 'some-value', }, 'type': 'CAA', }), ('42 100 thing-were-pointed.at 101', { 'data': { 'port': 42, 'priority': 100, 'target': 'thing-were-pointed.at', 'weight': 101, }, 'type': 'SRV', }), ): self.assertEqual(expected, provider._gen_key(data)) def test_cdn(self): provider = CloudflareProvider('test', 'email', 'token', True) # A CNAME for us to transform to ALIAS provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997642", "type": "CNAME", "name": "cname.unit.tests", "content": "www.unit.tests", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997642", "type": "A", "name": "a.unit.tests", "content": "1.1.1.1", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997642", "type": "A", "name": "a.unit.tests", "content": "1.1.1.2", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997642", "type": "A", "name": "multi.unit.tests", "content": "1.1.1.3", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, { "id": "fc12ab34cd5611334422ab3322997642", "type": "AAAA", "name": "multi.unit.tests", "content": "::1", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, ]) zone = Zone('unit.tests.', []) provider.populate(zone) # the two A records get merged into one CNAME record pointing to # the CDN. self.assertEquals(3, len(zone.records)) ordered = sorted(zone.records, key=lambda r: r.name) record = ordered[0] self.assertEquals('a', record.name) self.assertEquals('a.unit.tests.', record.fqdn) self.assertEquals('CNAME', record._type) self.assertEquals('a.unit.tests.cdn.cloudflare.net.', record.value) record = ordered[1] self.assertEquals('cname', record.name) self.assertEquals('cname.unit.tests.', record.fqdn) self.assertEquals('CNAME', record._type) self.assertEquals('cname.unit.tests.cdn.cloudflare.net.', record.value) record = ordered[2] self.assertEquals('multi', record.name) self.assertEquals('multi.unit.tests.', record.fqdn) self.assertEquals('CNAME', record._type) self.assertEquals('multi.unit.tests.cdn.cloudflare.net.', record.value) # CDN enabled records can't be updated, we don't know the real values # never point a Cloudflare record to itself. wanted = Zone('unit.tests.', []) wanted.add_record(Record.new(wanted, 'cname', { 'ttl': 300, 'type': 'CNAME', 'value': 'change.unit.tests.cdn.cloudflare.net.' })) wanted.add_record(Record.new(wanted, 'new', { 'ttl': 300, 'type': 'CNAME', 'value': 'new.unit.tests.cdn.cloudflare.net.' })) wanted.add_record(Record.new(wanted, 'created', { 'ttl': 300, 'type': 'CNAME', 'value': 'www.unit.tests.' })) plan = provider.plan(wanted) self.assertEquals(1, len(plan.changes)) def test_cdn_alias(self): provider = CloudflareProvider('test', 'email', 'token', True) # A CNAME for us to transform to ALIAS provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997642", "type": "CNAME", "name": "unit.tests", "content": "www.unit.tests", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } }, ]) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(1, len(zone.records)) record = list(zone.records)[0] self.assertEquals('', record.name) self.assertEquals('unit.tests.', record.fqdn) self.assertEquals('ALIAS', record._type) self.assertEquals('unit.tests.cdn.cloudflare.net.', record.value) # CDN enabled records can't be updated, we don't know the real values # never point a Cloudflare record to itself. wanted = Zone('unit.tests.', []) wanted.add_record(Record.new(wanted, '', { 'ttl': 300, 'type': 'ALIAS', 'value': 'change.unit.tests.cdn.cloudflare.net.' })) plan = provider.plan(wanted) self.assertEquals(False, hasattr(plan, 'changes')) def test_unproxiabletype_recordfor_returnsrecordwithnocloudflare(self): provider = CloudflareProvider('test', 'email', 'token') name = "unit.tests" _type = "NS" zone_records = [ { "id": "fc12ab34cd5611334422ab3322997654", "type": _type, "name": name, "content": "ns2.foo.bar", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } } ] provider.zone_records = Mock(return_value=zone_records) zone = Zone('unit.tests.', []) provider.populate(zone) record = provider._record_for(zone, name, _type, zone_records, False) self.assertFalse('cloudflare' in record._octodns) def test_proxiabletype_recordfor_retrecordwithcloudflareunproxied(self): provider = CloudflareProvider('test', 'email', 'token') name = "multi.unit.tests" _type = "AAAA" zone_records = [ { "id": "fc12ab34cd5611334422ab3322997642", "type": _type, "name": name, "content": "::1", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } } ] provider.zone_records = Mock(return_value=zone_records) zone = Zone('unit.tests.', []) provider.populate(zone) record = provider._record_for(zone, name, _type, zone_records, False) self.assertFalse(record._octodns['cloudflare']['proxied']) def test_proxiabletype_recordfor_returnsrecordwithcloudflareproxied(self): provider = CloudflareProvider('test', 'email', 'token') name = "multi.unit.tests" _type = "AAAA" zone_records = [ { "id": "fc12ab34cd5611334422ab3322997642", "type": _type, "name": name, "content": "::1", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } } ] provider.zone_records = Mock(return_value=zone_records) zone = Zone('unit.tests.', []) provider.populate(zone) record = provider._record_for(zone, name, _type, zone_records, False) self.assertTrue(record._octodns['cloudflare']['proxied']) def test_proxiedrecordandnewttl_includechange_returnsfalse(self): provider = CloudflareProvider('test', 'email', 'token') zone = Zone('unit.tests.', []) existing = set_record_proxied_flag( Record.new(zone, 'a', { 'ttl': 1, 'type': 'A', 'values': ['1.1.1.1', '2.2.2.2'] }), True ) new = Record.new(zone, 'a', { 'ttl': 300, 'type': 'A', 'values': ['1.1.1.1', '2.2.2.2'] }) change = Update(existing, new) include_change = provider._include_change(change) self.assertFalse(include_change) def test_unproxiabletype_gendata_returnsnoproxied(self): provider = CloudflareProvider('test', 'email', 'token') zone = Zone('unit.tests.', []) record = Record.new(zone, 'a', { 'ttl': 3600, 'type': 'NS', 'value': 'ns1.unit.tests.' }) data = next(provider._gen_data(record)) self.assertFalse('proxied' in data) def test_proxiabletype_gendata_returnsunproxied(self): provider = CloudflareProvider('test', 'email', 'token') zone = Zone('unit.tests.', []) record = set_record_proxied_flag( Record.new(zone, 'a', { 'ttl': 300, 'type': 'A', 'value': '1.2.3.4' }), False ) data = next(provider._gen_data(record)) self.assertFalse(data['proxied']) def test_proxiabletype_gendata_returnsproxied(self): provider = CloudflareProvider('test', 'email', 'token') zone = Zone('unit.tests.', []) record = set_record_proxied_flag( Record.new(zone, 'a', { 'ttl': 300, 'type': 'A', 'value': '1.2.3.4' }), True ) data = next(provider._gen_data(record)) self.assertTrue(data['proxied']) def test_createrecord_extrachanges_returnsemptylist(self): provider = CloudflareProvider('test', 'email', 'token') provider.zone_records = Mock(return_value=[]) existing = Zone('unit.tests.', []) provider.populate(existing) provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997642", "type": "CNAME", "name": "a.unit.tests", "content": "www.unit.tests", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } } ]) desired = Zone('unit.tests.', []) provider.populate(desired) changes = existing.changes(desired, provider) extra_changes = provider._extra_changes(existing, desired, changes) self.assertFalse(extra_changes) def test_updaterecord_extrachanges_returnsemptylist(self): provider = CloudflareProvider('test', 'email', 'token') provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997642", "type": "CNAME", "name": "a.unit.tests", "content": "www.unit.tests", "proxiable": True, "proxied": True, "ttl": 120, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } } ]) existing = Zone('unit.tests.', []) provider.populate(existing) provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997642", "type": "CNAME", "name": "a.unit.tests", "content": "www.unit.tests", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } } ]) desired = Zone('unit.tests.', []) provider.populate(desired) changes = existing.changes(desired, provider) extra_changes = provider._extra_changes(existing, desired, changes) self.assertFalse(extra_changes) def test_deleterecord_extrachanges_returnsemptylist(self): provider = CloudflareProvider('test', 'email', 'token') provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997642", "type": "CNAME", "name": "a.unit.tests", "content": "www.unit.tests", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } } ]) existing = Zone('unit.tests.', []) provider.populate(existing) provider.zone_records = Mock(return_value=[]) desired = Zone('unit.tests.', []) provider.populate(desired) changes = existing.changes(desired, provider) extra_changes = provider._extra_changes(existing, desired, changes) self.assertFalse(extra_changes) def test_proxify_extrachanges_returnsupdatelist(self): provider = CloudflareProvider('test', 'email', 'token') provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997642", "type": "CNAME", "name": "a.unit.tests", "content": "www.unit.tests", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } } ]) existing = Zone('unit.tests.', []) provider.populate(existing) provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997642", "type": "CNAME", "name": "a.unit.tests", "content": "www.unit.tests", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } } ]) desired = Zone('unit.tests.', []) provider.populate(desired) changes = existing.changes(desired, provider) extra_changes = provider._extra_changes(existing, desired, changes) self.assertEquals(1, len(extra_changes)) self.assertFalse( extra_changes[0].existing._octodns['cloudflare']['proxied'] ) self.assertTrue( extra_changes[0].new._octodns['cloudflare']['proxied'] ) def test_unproxify_extrachanges_returnsupdatelist(self): provider = CloudflareProvider('test', 'email', 'token') provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997642", "type": "CNAME", "name": "a.unit.tests", "content": "www.unit.tests", "proxiable": True, "proxied": True, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } } ]) existing = Zone('unit.tests.', []) provider.populate(existing) provider.zone_records = Mock(return_value=[ { "id": "fc12ab34cd5611334422ab3322997642", "type": "CNAME", "name": "a.unit.tests", "content": "www.unit.tests", "proxiable": True, "proxied": False, "ttl": 300, "locked": False, "zone_id": "ff12ab34cd5611334422ab3322997650", "zone_name": "unit.tests", "modified_on": "2017-03-11T18:01:43.420689Z", "created_on": "2017-03-11T18:01:43.420689Z", "meta": { "auto_added": False } } ]) desired = Zone('unit.tests.', []) provider.populate(desired) changes = existing.changes(desired, provider) extra_changes = provider._extra_changes(existing, desired, changes) self.assertEquals(1, len(extra_changes)) self.assertTrue( extra_changes[0].existing._octodns['cloudflare']['proxied'] ) self.assertFalse( extra_changes[0].new._octodns['cloudflare']['proxied'] ) def test_emailless_auth(self): provider = CloudflareProvider('test', token='token 123', email='email 234') headers = provider._sess.headers self.assertEquals('email 234', headers['X-Auth-Email']) self.assertEquals('token 123', headers['X-Auth-Key']) provider = CloudflareProvider('test', token='token 123') headers = provider._sess.headers self.assertEquals('Bearer token 123', headers['Authorization']) def test_retry_behavior(self): provider = CloudflareProvider('test', token='token 123', email='email 234', retry_period=0) result = { "success": True, "errors": [], "messages": [], "result": [], "result_info": { "count": 1, "per_page": 50 } } zone = Zone('unit.tests.', []) provider._request = Mock() # No retry required, just calls and is returned provider._zones = None provider._request.reset_mock() provider._request.side_effect = [result] self.assertEquals([], provider.zone_records(zone)) provider._request.assert_has_calls([call('GET', '/zones', params={'page': 1})]) # One retry required provider._zones = None provider._request.reset_mock() provider._request.side_effect = [ CloudflareRateLimitError('{}'), result ] self.assertEquals([], provider.zone_records(zone)) provider._request.assert_has_calls([call('GET', '/zones', params={'page': 1})]) # Two retries required provider._zones = None provider._request.reset_mock() provider._request.side_effect = [ CloudflareRateLimitError('{}'), CloudflareRateLimitError('{}'), result ] self.assertEquals([], provider.zone_records(zone)) provider._request.assert_has_calls([call('GET', '/zones', params={'page': 1})]) # # Exhaust our retries provider._zones = None provider._request.reset_mock() provider._request.side_effect = [ CloudflareRateLimitError({"errors": [{"message": "first"}]}), CloudflareRateLimitError({"errors": [{"message": "boo"}]}), CloudflareRateLimitError({"errors": [{"message": "boo"}]}), CloudflareRateLimitError({"errors": [{"message": "boo"}]}), CloudflareRateLimitError({"errors": [{"message": "last"}]}), ] with self.assertRaises(CloudflareRateLimitError) as ctx: provider.zone_records(zone) self.assertEquals('last', text_type(ctx.exception))
class TestGandiProvider(TestCase): expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) # We remove this record from the test zone as Gandi API reject it # (rightfully). expected._remove_record( Record.new(expected, 'sub', { 'ttl': 1800, 'type': 'NS', 'values': ['6.2.3.4.', '7.2.3.4.'] })) def test_populate(self): provider = GandiProvider('test_id', 'token') # 400 - Bad Request. with requests_mock() as mock: mock.get(ANY, status_code=400, text='{"status": "error", "errors": [{"location": ' '"body", "name": "items", "description": ' '"\'6.2.3.4.\': invalid hostname (param: ' '{\'rrset_type\': u\'NS\', \'rrset_ttl\': 3600, ' '\'rrset_name\': u\'sub\', \'rrset_values\': ' '[u\'6.2.3.4.\', u\'7.2.3.4.\']})"}, {"location": ' '"body", "name": "items", "description": ' '"\'7.2.3.4.\': invalid hostname (param: ' '{\'rrset_type\': u\'NS\', \'rrset_ttl\': 3600, ' '\'rrset_name\': u\'sub\', \'rrset_values\': ' '[u\'6.2.3.4.\', u\'7.2.3.4.\']})"}]}') with self.assertRaises(GandiClientBadRequest) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertIn('"status": "error"', str(ctx.exception)) # 401 - Unauthorized. with requests_mock() as mock: mock.get(ANY, status_code=401, text='{"code":401,"message":"The server could not verify ' 'that you authorized to access the document you ' 'requested. Either you supplied the wrong ' 'credentials (e.g., bad api key), or your access ' 'token has expired","object":"HTTPUnauthorized",' '"cause":"Unauthorized"}') with self.assertRaises(GandiClientUnauthorized) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertIn('"cause":"Unauthorized"', str(ctx.exception)) # 403 - Forbidden. with requests_mock() as mock: mock.get(ANY, status_code=403, text='{"code":403,"message":"Access was denied to this ' 'resource.","object":"HTTPForbidden","cause":' '"Forbidden"}') with self.assertRaises(GandiClientForbidden) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertIn('"cause":"Forbidden"', str(ctx.exception)) # 404 - Not Found. with requests_mock() as mock: mock.get(ANY, status_code=404, text='{"code": 404, "message": "The resource could not ' 'be found.", "object": "HTTPNotFound", "cause": ' '"Not Found"}') with self.assertRaises(GandiClientNotFound) as ctx: zone = Zone('unit.tests.', []) provider._client.zone(zone) self.assertIn('"cause": "Not Found"', str(ctx.exception)) # General error with requests_mock() as mock: mock.get(ANY, status_code=502, text='Things caught fire') with self.assertRaises(HTTPError) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(502, ctx.exception.response.status_code) # No diffs == no changes with requests_mock() as mock: base = 'https://api.gandi.net/v5/livedns/domains/unit.tests' \ '/records' with open('tests/fixtures/gandi-no-changes.json') as fh: mock.get(base, text=fh.read()) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(16, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEquals(0, len(changes)) del provider._zone_records[zone.name] # Default Gandi zone file. with requests_mock() as mock: base = 'https://api.gandi.net/v5/livedns/domains/unit.tests' \ '/records' with open('tests/fixtures/gandi-records.json') as fh: mock.get(base, text=fh.read()) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(11, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEquals(24, len(changes)) # 2nd populate makes no network calls/all from cache again = Zone('unit.tests.', []) provider.populate(again) self.assertEquals(11, len(again.records)) # bust the cache del provider._zone_records[zone.name] def test_apply(self): provider = GandiProvider('test_id', 'token') # Zone does not exists but can be created. with requests_mock() as mock: mock.get(ANY, status_code=404, text='{"code": 404, "message": "The resource could not ' 'be found.", "object": "HTTPNotFound", "cause": ' '"Not Found"}') mock.post(ANY, status_code=201, text='{"message": "Domain Created"}') plan = provider.plan(self.expected) provider.apply(plan) # Zone does not exists and can't be created. with requests_mock() as mock: mock.get(ANY, status_code=404, text='{"code": 404, "message": "The resource could not ' 'be found.", "object": "HTTPNotFound", "cause": ' '"Not Found"}') mock.post(ANY, status_code=404, text='{"code": 404, "message": "The resource could not ' 'be found.", "object": "HTTPNotFound", "cause": ' '"Not Found"}') with self.assertRaises( (GandiClientNotFound, GandiClientUnknownDomainName)) as ctx: plan = provider.plan(self.expected) provider.apply(plan) self.assertIn('This domain is not registered at Gandi.', str(ctx.exception)) resp = Mock() resp.json = Mock() provider._client._request = Mock(return_value=resp) with open('tests/fixtures/gandi-zone.json') as fh: zone = fh.read() # non-existent domain resp.json.side_effect = [ GandiClientNotFound(resp), # no zone in populate GandiClientNotFound(resp), # no domain during apply zone ] plan = provider.plan(self.expected) # No root NS, no ignored, no excluded, no LOC n = len(self.expected.records) - 6 self.assertEquals(n, len(plan.changes)) self.assertEquals(n, provider.apply(plan)) self.assertFalse(plan.exists) provider._client._request.assert_has_calls([ call('GET', '/livedns/domains/unit.tests/records'), call('GET', '/livedns/domains/unit.tests'), call('POST', '/livedns/domains', data={ 'fqdn': 'unit.tests', 'zone': {} }), call('POST', '/livedns/domains/unit.tests/records', data={ 'rrset_name': 'www.sub', 'rrset_ttl': 300, 'rrset_type': 'A', 'rrset_values': ['2.2.3.6'] }), call('POST', '/livedns/domains/unit.tests/records', data={ 'rrset_name': 'www', 'rrset_ttl': 300, 'rrset_type': 'A', 'rrset_values': ['2.2.3.6'] }), call( 'POST', '/livedns/domains/unit.tests/records', data={ 'rrset_name': 'txt', 'rrset_ttl': 600, 'rrset_type': 'TXT', 'rrset_values': [ 'Bah bah black sheep', 'have you any wool.', 'v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+of/long/string' '+with+numb3rs' ] }), call('POST', '/livedns/domains/unit.tests/records', data={ 'rrset_name': 'spf', 'rrset_ttl': 600, 'rrset_type': 'SPF', 'rrset_values': ['v=spf1 ip4:192.168.0.1/16-all'] }), call('POST', '/livedns/domains/unit.tests/records', data={ 'rrset_name': 'ptr', 'rrset_ttl': 300, 'rrset_type': 'PTR', 'rrset_values': ['foo.bar.com.'] }), call('POST', '/livedns/domains/unit.tests/records', data={ 'rrset_name': 'mx', 'rrset_ttl': 300, 'rrset_type': 'MX', 'rrset_values': [ '10 smtp-4.unit.tests.', '20 smtp-2.unit.tests.', '30 smtp-3.unit.tests.', '40 smtp-1.unit.tests.' ] }), call('POST', '/livedns/domains/unit.tests/records', data={ 'rrset_name': 'excluded', 'rrset_ttl': 3600, 'rrset_type': 'CNAME', 'rrset_values': ['unit.tests.'] }), call('POST', '/livedns/domains/unit.tests/records', data={ 'rrset_name': 'dname', 'rrset_ttl': 300, 'rrset_type': 'DNAME', 'rrset_values': ['unit.tests.'] }), call('POST', '/livedns/domains/unit.tests/records', data={ 'rrset_name': 'cname', 'rrset_ttl': 300, 'rrset_type': 'CNAME', 'rrset_values': ['unit.tests.'] }), call('POST', '/livedns/domains/unit.tests/records', data={ 'rrset_name': 'aaaa', 'rrset_ttl': 600, 'rrset_type': 'AAAA', 'rrset_values': ['2601:644:500:e210:62f8:1dff:feb8:947a'] }), call('POST', '/livedns/domains/unit.tests/records', data={ 'rrset_name': '_srv._tcp', 'rrset_ttl': 600, 'rrset_type': 'SRV', 'rrset_values': [ '10 20 30 foo-1.unit.tests.', '12 20 30 foo-2.unit.tests.' ] }), call('POST', '/livedns/domains/unit.tests/records', data={ 'rrset_name': '_pop3._tcp', 'rrset_ttl': 600, 'rrset_type': 'SRV', 'rrset_values': [ '0 0 0 .', ] }), call('POST', '/livedns/domains/unit.tests/records', data={ 'rrset_name': '_imap._tcp', 'rrset_ttl': 600, 'rrset_type': 'SRV', 'rrset_values': [ '0 0 0 .', ] }), call('POST', '/livedns/domains/unit.tests/records', data={ 'rrset_name': '@', 'rrset_ttl': 3600, 'rrset_type': 'SSHFP', 'rrset_values': [ '1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49', '1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73' ] }), call('POST', '/livedns/domains/unit.tests/records', data={ 'rrset_name': '@', 'rrset_ttl': 3600, 'rrset_type': 'CAA', 'rrset_values': ['0 issue "ca.unit.tests"'] }), call('POST', '/livedns/domains/unit.tests/records', data={ 'rrset_name': '@', 'rrset_ttl': 300, 'rrset_type': 'A', 'rrset_values': ['1.2.3.4', '1.2.3.5'] }) ]) # expected number of total calls self.assertEquals(19, provider._client._request.call_count) provider._client._request.reset_mock() # delete 1 and update 1 provider._client.zone_records = Mock( return_value=[{ 'rrset_name': 'www', 'rrset_ttl': 300, 'rrset_type': 'A', 'rrset_values': ['1.2.3.4'] }, { 'rrset_name': 'www', 'rrset_ttl': 300, 'rrset_type': 'A', 'rrset_values': ['2.2.3.4'] }, { 'rrset_name': 'ttl', 'rrset_ttl': 600, 'rrset_type': 'A', 'rrset_values': ['3.2.3.4'] }]) # Domain exists, we don't care about return resp.json.side_effect = ['{}'] wanted = Zone('unit.tests.', []) wanted.add_record( Record.new(wanted, 'ttl', { 'ttl': 300, 'type': 'A', 'value': '3.2.3.4' })) plan = provider.plan(wanted) self.assertTrue(plan.exists) self.assertEquals(2, len(plan.changes)) self.assertEquals(2, provider.apply(plan)) # recreate for update, and deletes for the 2 parts of the other provider._client._request.assert_has_calls([ call('DELETE', '/livedns/domains/unit.tests/records/www/A'), call('DELETE', '/livedns/domains/unit.tests/records/ttl/A'), call('POST', '/livedns/domains/unit.tests/records', data={ 'rrset_name': 'ttl', 'rrset_ttl': 300, 'rrset_type': 'A', 'rrset_values': ['3.2.3.4'] }) ], any_order=True)
class TestHetznerProvider(TestCase): expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) def test_populate(self): provider = HetznerProvider('test', 'token') # Bad auth with requests_mock() as mock: mock.get(ANY, status_code=401, text='{"message":"Invalid authentication credentials"}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('Unauthorized', text_type(ctx.exception)) # General error with requests_mock() as mock: mock.get(ANY, status_code=502, text='Things caught fire') with self.assertRaises(HTTPError) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(502, ctx.exception.response.status_code) # Non-existent zone doesn't populate anything with requests_mock() as mock: mock.get(ANY, status_code=404, text='{"zone":{"id":"","name":"","ttl":0,"registrar":"",' '"legacy_dns_host":"","legacy_ns":null,"ns":null,' '"created":"","verified":"","modified":"","project":"",' '"owner":"","permission":"","zone_type":{"id":"",' '"name":"","description":"","prices":null},"status":"",' '"paused":false,"is_secondary_dns":false,' '"txt_verification":{"name":"","token":""},' '"records_count":0},"error":{' '"message":"zone not found","code":404}}') zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(set(), zone.records) # No diffs == no changes with requests_mock() as mock: base = provider._client.BASE_URL with open('tests/fixtures/hetzner-zones.json') as fh: mock.get('{}/zones'.format(base), text=fh.read()) with open('tests/fixtures/hetzner-records.json') as fh: mock.get('{}/records'.format(base), text=fh.read()) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(13, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEquals(0, len(changes)) # 2nd populate makes no network calls/all from cache again = Zone('unit.tests.', []) provider.populate(again) self.assertEquals(13, len(again.records)) # bust the cache del provider._zone_records[zone.name] def test_apply(self): provider = HetznerProvider('test', 'token') resp = Mock() resp.json = Mock() provider._client._do = Mock(return_value=resp) domain_after_creation = {'zone': { 'id': 'unit.tests', 'name': 'unit.tests', 'ttl': 3600, }} # non-existent domain, create everything resp.json.side_effect = [ HetznerClientNotFound, # no zone in populate HetznerClientNotFound, # no zone during apply domain_after_creation, ] plan = provider.plan(self.expected) # No root NS, no ignored, no excluded, no unsupported n = len(self.expected.records) - 9 self.assertEquals(n, len(plan.changes)) self.assertEquals(n, provider.apply(plan)) self.assertFalse(plan.exists) provider._client._do.assert_has_calls([ # created the zone call('POST', '/zones', None, { 'name': 'unit.tests', 'ttl': None, }), # created all the records with their expected data call('POST', '/records', data={ 'name': '@', 'ttl': 300, 'type': 'A', 'value': '1.2.3.4', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': '@', 'ttl': 300, 'type': 'A', 'value': '1.2.3.5', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': '@', 'ttl': 3600, 'type': 'CAA', 'value': '0 issue "ca.unit.tests"', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': '_imap._tcp', 'ttl': 600, 'type': 'SRV', 'value': '0 0 0 .', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': '_pop3._tcp', 'ttl': 600, 'type': 'SRV', 'value': '0 0 0 .', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': '_srv._tcp', 'ttl': 600, 'type': 'SRV', 'value': '10 20 30 foo-1.unit.tests.', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': '_srv._tcp', 'ttl': 600, 'type': 'SRV', 'value': '12 20 30 foo-2.unit.tests.', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': 'aaaa', 'ttl': 600, 'type': 'AAAA', 'value': '2601:644:500:e210:62f8:1dff:feb8:947a', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': 'cname', 'ttl': 300, 'type': 'CNAME', 'value': 'unit.tests.', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': 'included', 'ttl': 3600, 'type': 'CNAME', 'value': 'unit.tests.', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': 'mx', 'ttl': 300, 'type': 'MX', 'value': '10 smtp-4.unit.tests.', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': 'mx', 'ttl': 300, 'type': 'MX', 'value': '20 smtp-2.unit.tests.', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': 'mx', 'ttl': 300, 'type': 'MX', 'value': '30 smtp-3.unit.tests.', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': 'mx', 'ttl': 300, 'type': 'MX', 'value': '40 smtp-1.unit.tests.', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': 'sub', 'ttl': 3600, 'type': 'NS', 'value': '6.2.3.4.', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': 'sub', 'ttl': 3600, 'type': 'NS', 'value': '7.2.3.4.', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': 'txt', 'ttl': 600, 'type': 'TXT', 'value': 'Bah bah black sheep', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': 'txt', 'ttl': 600, 'type': 'TXT', 'value': 'have you any wool.', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': 'txt', 'ttl': 600, 'type': 'TXT', 'value': 'v=DKIM1;k=rsa;s=email;h=sha256;' 'p=A/kinda+of/long/string+with+numb3rs', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': 'www', 'ttl': 300, 'type': 'A', 'value': '2.2.3.6', 'zone_id': 'unit.tests', }), call('POST', '/records', data={ 'name': 'www.sub', 'ttl': 300, 'type': 'A', 'value': '2.2.3.6', 'zone_id': 'unit.tests', }), ]) self.assertEquals(24, provider._client._do.call_count) provider._client._do.reset_mock() # delete 1 and update 1 provider._client.zone_get = Mock(return_value={ 'id': 'unit.tests', 'name': 'unit.tests', 'ttl': 3600, }) provider._client.zone_records_get = Mock(return_value=[ { 'type': 'A', 'id': 'one', 'created': '0000-00-00T00:00:00Z', 'modified': '0000-00-00T00:00:00Z', 'zone_id': 'unit.tests', 'name': 'www', 'value': '1.2.3.4', 'ttl': 300, }, { 'type': 'A', 'id': 'two', 'created': '0000-00-00T00:00:00Z', 'modified': '0000-00-00T00:00:00Z', 'zone_id': 'unit.tests', 'name': 'www', 'value': '2.2.3.4', 'ttl': 300, }, { 'type': 'A', 'id': 'three', 'created': '0000-00-00T00:00:00Z', 'modified': '0000-00-00T00:00:00Z', 'zone_id': 'unit.tests', 'name': 'ttl', 'value': '3.2.3.4', 'ttl': 600, }, ]) # Domain exists, we don't care about return resp.json.side_effect = ['{}'] wanted = Zone('unit.tests.', []) wanted.add_record(Record.new(wanted, 'ttl', { 'ttl': 300, 'type': 'A', 'value': '3.2.3.4', })) plan = provider.plan(wanted) self.assertTrue(plan.exists) self.assertEquals(2, len(plan.changes)) self.assertEquals(2, provider.apply(plan)) # recreate for update, and delete for the 2 parts of the other provider._client._do.assert_has_calls([ call('POST', '/records', data={ 'name': 'ttl', 'ttl': 300, 'type': 'A', 'value': '3.2.3.4', 'zone_id': 'unit.tests', }), call('DELETE', '/records/one'), call('DELETE', '/records/two'), call('DELETE', '/records/three'), ], any_order=True)
class TestDnsMadeEasyProvider(TestCase): expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) # Our test suite differs a bit, add our NS and remove the simple one expected.add_record( Record.new( expected, 'under', { 'ttl': 3600, 'type': 'NS', 'values': [ 'ns1.unit.tests.', 'ns2.unit.tests.', ] })) # Add some ALIAS records expected.add_record( Record.new(expected, '', { 'ttl': 1800, 'type': 'ALIAS', 'value': 'aname.unit.tests.' })) for record in list(expected.records): if record.name == 'sub' and record._type == 'NS': expected._remove_record(record) break def test_populate(self): provider = DnsMadeEasyProvider('test', 'api', 'secret') # Bad auth with requests_mock() as mock: mock.get(ANY, status_code=401, text='{"error": ["API key not found"]}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('Unauthorized', text_type(ctx.exception)) # Bad request with requests_mock() as mock: mock.get(ANY, status_code=400, text='{"error": ["Rate limit exceeded"]}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('\n - Rate limit exceeded', text_type(ctx.exception)) # General error with requests_mock() as mock: mock.get(ANY, status_code=502, text='Things caught fire') with self.assertRaises(HTTPError) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(502, ctx.exception.response.status_code) # Non-existent zone doesn't populate anything with requests_mock() as mock: mock.get(ANY, status_code=404, text='<html><head></head><body></body></html>') zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(set(), zone.records) # No diffs == no changes with requests_mock() as mock: base = 'https://api.dnsmadeeasy.com/V2.0/dns/managed' with open('tests/fixtures/dnsmadeeasy-domains.json') as fh: mock.get(f'{base}/', text=fh.read()) with open('tests/fixtures/dnsmadeeasy-records.json') as fh: mock.get(f'{base}/123123/records', text=fh.read()) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(14, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEquals(0, len(changes)) # 2nd populate makes no network calls/all from cache again = Zone('unit.tests.', []) provider.populate(again) self.assertEquals(14, len(again.records)) # bust the cache del provider._zone_records[zone.name] def test_apply(self): # Create provider with sandbox enabled provider = DnsMadeEasyProvider('test', 'api', 'secret', True) resp = Mock() resp.json = Mock() provider._client._request = Mock(return_value=resp) with open('tests/fixtures/dnsmadeeasy-domains.json') as fh: domains = json.load(fh) # non-existent domain, create everything resp.json.side_effect = [ DnsMadeEasyClientNotFound, # no zone in populate DnsMadeEasyClientNotFound, # no domain during apply domains ] plan = provider.plan(self.expected) # No root NS, no ignored, no excluded, no unsupported n = len(self.expected.records) - 10 self.assertEquals(n, len(plan.changes)) self.assertEquals(n, provider.apply(plan)) provider._client._request.assert_has_calls([ # created the domain call('POST', '/', data={'name': 'unit.tests'}), # get all domains to build the cache call('GET', '/'), # created at least some of the record with expected data call('POST', '/123123/records', data={ 'type': 'A', 'name': '', 'value': '1.2.3.4', 'ttl': 300 }), call('POST', '/123123/records', data={ 'type': 'A', 'name': '', 'value': '1.2.3.5', 'ttl': 300 }), call('POST', '/123123/records', data={ 'type': 'ANAME', 'name': '', 'value': 'aname.unit.tests.', 'ttl': 1800 }), call('POST', '/123123/records', data={ 'name': '', 'value': 'ca.unit.tests', 'issuerCritical': 0, 'caaType': 'issue', 'ttl': 3600, 'type': 'CAA' }), call('POST', '/123123/records', data={ 'name': '_srv._tcp', 'weight': 20, 'value': 'foo-1.unit.tests.', 'priority': 10, 'ttl': 600, 'type': 'SRV', 'port': 30 }), ]) self.assertEquals(26, provider._client._request.call_count) provider._client._request.reset_mock() # delete 1 and update 1 provider._client.records = Mock(return_value=[{ 'id': 11189897, 'name': 'www', 'value': '1.2.3.4', 'ttl': 300, 'type': 'A', }, { 'id': 11189898, 'name': 'www', 'value': '2.2.3.4', 'ttl': 300, 'type': 'A', }, { 'id': 11189899, 'name': 'ttl', 'value': '3.2.3.4', 'ttl': 600, 'type': 'A', }]) # Domain exists, we don't care about return resp.json.side_effect = ['{}'] wanted = Zone('unit.tests.', []) wanted.add_record( Record.new(wanted, 'ttl', { 'ttl': 300, 'type': 'A', 'value': '3.2.3.4' })) plan = provider.plan(wanted) self.assertEquals(2, len(plan.changes)) self.assertEquals(2, provider.apply(plan)) # recreate for update, and deletes for the 2 parts of the other provider._client._request.assert_has_calls([ call('POST', '/123123/records', data={ 'value': '3.2.3.4', 'type': 'A', 'name': 'ttl', 'ttl': 300 }), call('DELETE', '/123123/records/11189899'), call('DELETE', '/123123/records/11189897'), call('DELETE', '/123123/records/11189898') ], any_order=True)
class TestDnsimpleProvider(TestCase): expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) # Our test suite differs a bit, add our NS and remove the simple one expected.add_record( Record.new( expected, 'under', { 'ttl': 3600, 'type': 'NS', 'values': [ 'ns1.unit.tests.', 'ns2.unit.tests.', ] })) for record in list(expected.records): if record.name == 'sub' and record._type == 'NS': expected._remove_record(record) break def test_populate(self): # Sandbox provider = DnsimpleProvider('test', 'token', 42, 'true') self.assertTrue('sandbox' in provider._client.base) provider = DnsimpleProvider('test', 'token', 42) self.assertFalse('sandbox' in provider._client.base) # Bad auth with requests_mock() as mock: mock.get(ANY, status_code=401, text='{"message": "Authentication failed"}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('Unauthorized', text_type(ctx.exception)) # General error with requests_mock() as mock: mock.get(ANY, status_code=502, text='Things caught fire') with self.assertRaises(HTTPError) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(502, ctx.exception.response.status_code) # Non-existent zone doesn't populate anything with requests_mock() as mock: mock.get(ANY, status_code=404, text='{"message": "Domain `foo.bar` not found"}') zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(set(), zone.records) # No diffs == no changes with requests_mock() as mock: base = 'https://api.dnsimple.com/v2/42/zones/unit.tests/' \ 'records?page=' with open('tests/fixtures/dnsimple-page-1.json') as fh: mock.get('{}{}'.format(base, 1), text=fh.read()) with open('tests/fixtures/dnsimple-page-2.json') as fh: mock.get('{}{}'.format(base, 2), text=fh.read()) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(16, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEquals(0, len(changes)) # 2nd populate makes no network calls/all from cache again = Zone('unit.tests.', []) provider.populate(again) self.assertEquals(16, len(again.records)) # bust the cache del provider._zone_records[zone.name] # test handling of invalid content with requests_mock() as mock: with open('tests/fixtures/dnsimple-invalid-content.json') as fh: mock.get(ANY, text=fh.read()) zone = Zone('unit.tests.', []) provider.populate(zone, lenient=True) self.assertEquals( set([ Record.new(zone, '', { 'ttl': 3600, 'type': 'SSHFP', 'values': [] }, lenient=True), Record.new(zone, '_srv._tcp', { 'ttl': 600, 'type': 'SRV', 'values': [] }, lenient=True), Record.new(zone, 'naptr', { 'ttl': 600, 'type': 'NAPTR', 'values': [] }, lenient=True), ]), zone.records) def test_apply(self): provider = DnsimpleProvider('test', 'token', 42) resp = Mock() resp.json = Mock() provider._client._request = Mock(return_value=resp) # non-existent domain, create everything resp.json.side_effect = [ DnsimpleClientNotFound, # no zone in populate DnsimpleClientNotFound, # no domain during apply ] plan = provider.plan(self.expected) # No root NS, no ignored, no excluded n = len(self.expected.records) - 7 self.assertEquals(n, len(plan.changes)) self.assertEquals(n, provider.apply(plan)) self.assertFalse(plan.exists) provider._client._request.assert_has_calls([ # created the domain call('POST', '/domains', data={'name': 'unit.tests'}), # created at least some of the record with expected data call('POST', '/zones/unit.tests/records', data={ 'content': '1.2.3.4', 'type': 'A', 'name': '', 'ttl': 300 }), call('POST', '/zones/unit.tests/records', data={ 'content': '1.2.3.5', 'type': 'A', 'name': '', 'ttl': 300 }), call('POST', '/zones/unit.tests/records', data={ 'content': '0 issue "ca.unit.tests"', 'type': 'CAA', 'name': '', 'ttl': 3600 }), call('POST', '/zones/unit.tests/records', data={ 'content': '1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49', 'type': 'SSHFP', 'name': '', 'ttl': 3600 }), call('POST', '/zones/unit.tests/records', data={ 'content': '1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73', 'type': 'SSHFP', 'name': '', 'ttl': 3600 }), call('POST', '/zones/unit.tests/records', data={ 'content': '20 30 foo-1.unit.tests.', 'priority': 10, 'type': 'SRV', 'name': '_srv._tcp', 'ttl': 600 }), ]) # expected number of total calls self.assertEquals(28, provider._client._request.call_count) provider._client._request.reset_mock() # delete 1 and update 1 provider._client.records = Mock(return_value=[{ 'id': 11189897, 'name': 'www', 'content': '1.2.3.4', 'ttl': 300, 'type': 'A', }, { 'id': 11189898, 'name': 'www', 'content': '2.2.3.4', 'ttl': 300, 'type': 'A', }, { 'id': 11189899, 'name': 'ttl', 'content': '3.2.3.4', 'ttl': 600, 'type': 'A', }]) # Domain exists, we don't care about return resp.json.side_effect = ['{}'] wanted = Zone('unit.tests.', []) wanted.add_record( Record.new(wanted, 'ttl', { 'ttl': 300, 'type': 'A', 'value': '3.2.3.4' })) plan = provider.plan(wanted) self.assertTrue(plan.exists) self.assertEquals(2, len(plan.changes)) self.assertEquals(2, provider.apply(plan)) # recreate for update, and deletes for the 2 parts of the other provider._client._request.assert_has_calls([ call('POST', '/zones/unit.tests/records', data={ 'content': '3.2.3.4', 'type': 'A', 'name': 'ttl', 'ttl': 300 }), call('DELETE', '/zones/unit.tests/records/11189899'), call('DELETE', '/zones/unit.tests/records/11189897'), call('DELETE', '/zones/unit.tests/records/11189898') ], any_order=True)
class TestGCoreProvider(TestCase): expected = Zone("unit.tests.", []) source = YamlProvider("test", join(dirname(__file__), "config")) source.populate(expected) default_filters = [ { "type": "geodns" }, { "type": "default", "limit": 1, "strict": False, }, { "type": "first_n", "limit": 1 }, ] def test_populate(self): provider = GCoreProvider("test_id", token="token") # TC: 400 - Bad Request. with requests_mock() as mock: mock.get(ANY, status_code=400, text='{"error":"bad body"}') with self.assertRaises(GCoreClientBadRequest) as ctx: zone = Zone("unit.tests.", []) provider.populate(zone) self.assertIn('"error":"bad body"', str(ctx.exception)) # TC: 404 - Not Found. with requests_mock() as mock: mock.get(ANY, status_code=404, text='{"error":"zone is not found"}') with self.assertRaises(GCoreClientNotFound) as ctx: zone = Zone("unit.tests.", []) provider._client.zone(zone.name) self.assertIn('"error":"zone is not found"', str(ctx.exception)) # TC: General error with requests_mock() as mock: mock.get(ANY, status_code=500, text="Things caught fire") with self.assertRaises(GCoreClientException) as ctx: zone = Zone("unit.tests.", []) provider.populate(zone) self.assertEqual("Things caught fire", str(ctx.exception)) # TC: No credentials or token error with requests_mock() as mock: with self.assertRaises(ValueError) as ctx: GCoreProvider("test_id") self.assertEqual( "either token or login & password must be set", str(ctx.exception), ) # TC: Auth with login password with requests_mock() as mock: def match_body(request): return {"username": "******", "password": "******"} == request.json() auth_url = "http://api/auth/jwt/login" mock.post( auth_url, additional_matcher=match_body, status_code=200, json={"access": "access"}, ) providerPassword = GCoreProvider( "test_id", url="http://dns", auth_url="http://api", login="******", password="******", ) assert mock.called # make sure token passed in header zone_rrset_url = "http://dns/zones/unit.tests/rrsets?all=true" mock.get( zone_rrset_url, request_headers={"Authorization": "Bearer access"}, status_code=404, ) zone = Zone("unit.tests.", []) assert not providerPassword.populate(zone) # TC: No diffs == no changes with requests_mock() as mock: base = "https://dnsapi.gcorelabs.com/v2/zones/unit.tests/rrsets" with open("tests/fixtures/gcore-no-changes.json") as fh: mock.get(base, text=fh.read()) zone = Zone("unit.tests.", []) provider.populate(zone) self.assertEqual(14, len(zone.records)) self.assertEqual( { "", "_imap._tcp", "_pop3._tcp", "_srv._tcp", "aaaa", "cname", "excluded", "mx", "ptr", "sub", "txt", "www", "www.sub", }, {r.name for r in zone.records}, ) changes = self.expected.changes(zone, provider) self.assertEqual(0, len(changes)) # TC: 4 create (dynamic) + 1 removed + 7 modified with requests_mock() as mock: base = "https://dnsapi.gcorelabs.com/v2/zones/unit.tests/rrsets" with open("tests/fixtures/gcore-records.json") as fh: mock.get(base, text=fh.read()) zone = Zone("unit.tests.", []) provider.populate(zone) self.assertEqual(16, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEqual(11, len(changes)) self.assertEqual( 3, len([c for c in changes if isinstance(c, Create)])) self.assertEqual( 1, len([c for c in changes if isinstance(c, Delete)])) self.assertEqual( 7, len([c for c in changes if isinstance(c, Update)])) # TC: no pools can be built with requests_mock() as mock: base = "https://dnsapi.gcorelabs.com/v2/zones/unit.tests/rrsets" mock.get( base, json={ "rrsets": [{ "name": "unit.tests.", "type": "A", "ttl": 300, "filters": self.default_filters, "resource_records": [{ "content": ["7.7.7.7"] }], }] }, ) zone = Zone("unit.tests.", []) with self.assertRaises(RuntimeError) as ctx: provider.populate(zone) self.assertTrue( str(ctx.exception).startswith( "filter is enabled, but no pools where built for"), f"{ctx.exception} - is not start from desired text", ) def test_apply(self): provider = GCoreProvider("test_id", url="http://api", token="token") # TC: Zone does not exists but can be created. with requests_mock() as mock: mock.get(ANY, status_code=404, text='{"error":"zone is not found"}') mock.post(ANY, status_code=200, text='{"id":1234}') plan = provider.plan(self.expected) provider.apply(plan) # TC: Zone does not exists and can't be created. with requests_mock() as mock: mock.get(ANY, status_code=404, text='{"error":"zone is not found"}') mock.post( ANY, status_code=400, text='{"error":"parent zone is already' ' occupied by another client"}', ) with self.assertRaises( (GCoreClientNotFound, GCoreClientBadRequest)) as ctx: plan = provider.plan(self.expected) provider.apply(plan) self.assertIn( "parent zone is already occupied by another client", str(ctx.exception), ) resp = Mock() resp.json = Mock() provider._client._request = Mock(return_value=resp) with open("tests/fixtures/gcore-zone.json") as fh: zone = fh.read() # non-existent domain resp.json.side_effect = [ GCoreClientNotFound(resp), # no zone in populate GCoreClientNotFound(resp), # no domain during apply zone, ] plan = provider.plan(self.expected) # TC: create all self.assertEqual(13, len(plan.changes)) self.assertEqual(13, provider.apply(plan)) self.assertFalse(plan.exists) provider._client._request.assert_has_calls([ call( "GET", "http://api/zones/unit.tests/rrsets", params={"all": "true"}, ), call("GET", "http://api/zones/unit.tests"), call("POST", "http://api/zones", data={"name": "unit.tests"}), call( "POST", "http://api/zones/unit.tests/www.sub.unit.tests./A", data={ "ttl": 300, "resource_records": [{ "content": ["2.2.3.6"] }], }, ), call( "POST", "http://api/zones/unit.tests/www.unit.tests./A", data={ "ttl": 300, "resource_records": [{ "content": ["2.2.3.6"] }], }, ), call( "POST", "http://api/zones/unit.tests/txt.unit.tests./TXT", data={ "ttl": 600, "resource_records": [ { "content": ["Bah bah black sheep"] }, { "content": ["have you any wool."] }, { "content": [ "v=DKIM1;k=rsa;s=email;h=sha256;p=A/kinda+" "of/long/string+with+numb3rs" ] }, ], }, ), call( "POST", "http://api/zones/unit.tests/sub.unit.tests./NS", data={ "ttl": 3600, "resource_records": [ { "content": ["6.2.3.4."] }, { "content": ["7.2.3.4."] }, ], }, ), call( "POST", "http://api/zones/unit.tests/ptr.unit.tests./PTR", data={ "ttl": 300, "resource_records": [ { "content": ["foo.bar.com."] }, ], }, ), call( "POST", "http://api/zones/unit.tests/mx.unit.tests./MX", data={ "ttl": 300, "resource_records": [ { "content": [10, "smtp-4.unit.tests."] }, { "content": [20, "smtp-2.unit.tests."] }, { "content": [30, "smtp-3.unit.tests."] }, { "content": [40, "smtp-1.unit.tests."] }, ], }, ), call( "POST", "http://api/zones/unit.tests/excluded.unit.tests./CNAME", data={ "ttl": 3600, "resource_records": [{ "content": ["unit.tests."] }], }, ), call( "POST", "http://api/zones/unit.tests/cname.unit.tests./CNAME", data={ "ttl": 300, "resource_records": [{ "content": ["unit.tests."] }], }, ), call( "POST", "http://api/zones/unit.tests/aaaa.unit.tests./AAAA", data={ "ttl": 600, "resource_records": [{ "content": ["2601:644:500:e210:62f8:1dff:feb8:947a"] }], }, ), call( "POST", "http://api/zones/unit.tests/_srv._tcp.unit.tests./SRV", data={ "ttl": 600, "resource_records": [ { "content": [10, 20, 30, "foo-1.unit.tests."] }, { "content": [12, 20, 30, "foo-2.unit.tests."] }, ], }, ), call( "POST", "http://api/zones/unit.tests/_pop3._tcp.unit.tests./SRV", data={ "ttl": 600, "resource_records": [{ "content": [0, 0, 0, "."] }], }, ), call( "POST", "http://api/zones/unit.tests/_imap._tcp.unit.tests./SRV", data={ "ttl": 600, "resource_records": [{ "content": [0, 0, 0, "."] }], }, ), call( "POST", "http://api/zones/unit.tests/unit.tests./A", data={ "ttl": 300, "resource_records": [ { "content": ["1.2.3.4"] }, { "content": ["1.2.3.5"] }, ], }, ), ]) # expected number of total calls self.assertEqual(16, provider._client._request.call_count) # TC: delete 1 and update 1 provider._client._request.reset_mock() provider._client.zone_records = Mock(return_value=[ { "name": "www", "ttl": 300, "type": "A", "resource_records": [{ "content": ["1.2.3.4"] }], }, { "name": "ttl", "ttl": 600, "type": "A", "resource_records": [{ "content": ["3.2.3.4"] }], }, ]) # Domain exists, we don't care about return resp.json.side_effect = ["{}"] wanted = Zone("unit.tests.", []) wanted.add_record( Record.new(wanted, "ttl", { "ttl": 300, "type": "A", "value": "3.2.3.4" })) plan = provider.plan(wanted) self.assertTrue(plan.exists) self.assertEqual(2, len(plan.changes)) self.assertEqual(2, provider.apply(plan)) provider._client._request.assert_has_calls([ call("DELETE", "http://api/zones/unit.tests/www.unit.tests./A"), call( "PUT", "http://api/zones/unit.tests/ttl.unit.tests./A", data={ "ttl": 300, "resource_records": [{ "content": ["3.2.3.4"] }], }, ), ]) # TC: create dynamics provider._client._request.reset_mock() provider._client.zone_records = Mock(return_value=[]) # Domain exists, we don't care about return resp.json.side_effect = ["{}"] wanted = Zone("unit.tests.", []) wanted.add_record( Record.new( wanted, "geo-simple", { "ttl": 300, "type": "A", "value": "3.3.3.3", "dynamic": { "pools": { "pool-1": { "fallback": "other", "values": [ { "value": "1.1.1.1" }, { "value": "1.1.1.2" }, ], }, "pool-2": { "fallback": "other", "values": [ { "value": "2.2.2.1" }, ], }, "other": { "values": [{ "value": "3.3.3.3" }] }, }, "rules": [ { "pool": "pool-1", "geos": ["EU-RU"] }, { "pool": "pool-2", "geos": ["EU"] }, { "pool": "other" }, ], }, }, ), ) wanted.add_record( Record.new( wanted, "geo-defaults", { "ttl": 300, "type": "A", "value": "3.2.3.4", "dynamic": { "pools": { "pool-1": { "values": [ { "value": "2.2.2.1" }, ], }, }, "rules": [ { "pool": "pool-1", "geos": ["EU"] }, ], }, }, ), ) wanted.add_record( Record.new( wanted, "cname-smpl", { "ttl": 300, "type": "CNAME", "value": "en.unit.tests.", "dynamic": { "pools": { "pool-1": { "fallback": "other", "values": [ { "value": "ru-1.unit.tests." }, { "value": "ru-2.unit.tests." }, ], }, "pool-2": { "fallback": "other", "values": [ { "value": "eu.unit.tests." }, ], }, "other": { "values": [{ "value": "en.unit.tests." }] }, }, "rules": [ { "pool": "pool-1", "geos": ["EU-RU"] }, { "pool": "pool-2", "geos": ["EU"] }, { "pool": "other" }, ], }, }, ), ) wanted.add_record( Record.new( wanted, "cname-dflt", { "ttl": 300, "type": "CNAME", "value": "en.unit.tests.", "dynamic": { "pools": { "pool-1": { "values": [ { "value": "eu.unit.tests." }, ], }, }, "rules": [ { "pool": "pool-1", "geos": ["EU"] }, ], }, }, ), ) plan = provider.plan(wanted) self.assertTrue(plan.exists) self.assertEqual(4, len(plan.changes)) self.assertEqual(4, provider.apply(plan)) provider._client._request.assert_has_calls([ call( "POST", "http://api/zones/unit.tests/geo-simple.unit.tests./A", data={ "ttl": 300, "filters": self.default_filters, "resource_records": [ { "content": ["1.1.1.1"], "meta": { "countries": ["RU"] }, }, { "content": ["1.1.1.2"], "meta": { "countries": ["RU"] }, }, { "content": ["2.2.2.1"], "meta": { "continents": ["EU"] }, }, { "content": ["3.3.3.3"], "meta": { "default": True }, }, ], }, ), call( "POST", "http://api/zones/unit.tests/geo-defaults.unit.tests./A", data={ "ttl": 300, "filters": self.default_filters, "resource_records": [ { "content": ["2.2.2.1"], "meta": { "continents": ["EU"] }, }, { "content": ["3.2.3.4"], }, ], }, ), call( "POST", "http://api/zones/unit.tests/cname-smpl.unit.tests./CNAME", data={ "ttl": 300, "filters": self.default_filters, "resource_records": [ { "content": ["ru-1.unit.tests."], "meta": { "countries": ["RU"] }, }, { "content": ["ru-2.unit.tests."], "meta": { "countries": ["RU"] }, }, { "content": ["eu.unit.tests."], "meta": { "continents": ["EU"] }, }, { "content": ["en.unit.tests."], "meta": { "default": True }, }, ], }, ), call( "POST", "http://api/zones/unit.tests/cname-dflt.unit.tests./CNAME", data={ "ttl": 300, "filters": self.default_filters, "resource_records": [ { "content": ["eu.unit.tests."], "meta": { "continents": ["EU"] }, }, { "content": ["en.unit.tests."], }, ], }, ), ])
def test_provider(self): source = YamlProvider('test', join(dirname(__file__), 'config')) zone = Zone('unit.tests.', []) # With target we don't add anything source.populate(zone, target=source) self.assertEquals(0, len(zone.records)) # without it we see everything source.populate(zone) self.assertEquals(18, len(zone.records)) # Assumption here is that a clean round-trip means that everything # worked as expected, data that went in came back out and could be # pulled in yet again and still match up. That assumes that the input # data completely exercises things. This assumption can be tested by # relatively well by running # ./script/coverage tests/test_octodns_provider_yaml.py and # looking at the coverage file # ./htmlcov/octodns_provider_yaml_py.html with TemporaryDirectory() as td: # Add some subdirs to make sure that it can create them directory = join(td.dirname, 'sub', 'dir') yaml_file = join(directory, 'unit.tests.yaml') target = YamlProvider('test', directory) # We add everything plan = target.plan(zone) self.assertEquals( 14, len(filter(lambda c: isinstance(c, Create), plan.changes))) self.assertFalse(isfile(yaml_file)) # Now actually do it self.assertEquals(14, target.apply(plan)) self.assertTrue(isfile(yaml_file)) # There should be no changes after the round trip reloaded = Zone('unit.tests.', []) target.populate(reloaded) self.assertFalse(zone.changes(reloaded, target=source)) # A 2nd sync should still create everything plan = target.plan(zone) self.assertEquals( 14, len(filter(lambda c: isinstance(c, Create), plan.changes))) with open(yaml_file) as fh: data = safe_load(fh.read()) # these are stored as plural 'values' for r in data['']: self.assertTrue('values' in r) self.assertTrue('values' in data['mx']) self.assertTrue('values' in data['naptr']) self.assertTrue('values' in data['_srv._tcp']) self.assertTrue('values' in data['txt']) # these are stored as singular 'value' self.assertTrue('value' in data['aaaa']) self.assertTrue('value' in data['ptr']) self.assertTrue('value' in data['spf']) self.assertTrue('value' in data['www'])
def make_expected(self): expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) return expected
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', text_type(err.exception)) with self.assertRaises(AssertionError) as err: remove_trailing_dot('unit.tests') self.assertEquals('Value already missing trailing dot', text_type(err.exception)) 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_txt(self): test_data = { 'raw_values': [{ 'value': 'v=DKIM1; k=rsa; p=prawf', 'ttl': 60 }, { 'value': 'prawf prawf dyma prawf', 'ttl': 300 }], 'zone': 'unit.tests.', } test_txt = MythicBeastsProvider._data_for_TXT('', test_data) self.assertTrue(isinstance(test_txt, dict)) self.assertEquals(2, len(test_txt['values'])) self.assertEquals('v=DKIM1\\; k=rsa\\; p=prawf', test_txt['values'][0]) 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', text_type(err.exception)) 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', text_type(err.exception)) 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', text_type(err.exception)) 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', text_type(err.exception)) def test_command_generation(self): zone = Zone('unit.tests.', []) zone.add_record( Record.new(zone, '', { 'ttl': 60, 'type': 'ALIAS', 'value': 'alias.unit.tests.', })) zone.add_record( Record.new( zone, 'prawf-ns', { 'ttl': 300, 'type': 'NS', 'values': [ 'alias.unit.tests.', 'alias2.unit.tests.', ], })) zone.add_record( Record.new(zone, 'prawf-a', { 'ttl': 60, 'type': 'A', 'values': [ '1.2.3.4', '5.6.7.8', ], })) zone.add_record( Record.new( zone, 'prawf-aaaa', { 'ttl': 60, 'type': 'AAAA', 'values': [ 'a:a::a', 'b:b::b', 'c:c::c:c', ], })) zone.add_record( Record.new(zone, 'prawf-txt', { 'ttl': 60, 'type': 'TXT', 'value': 'prawf prawf dyma prawf', })) zone.add_record( Record.new(zone, 'prawf-txt2', { 'ttl': 60, 'type': 'TXT', 'value': 'v=DKIM1\\; k=rsa\\; p=prawf', })) with requests_mock() as mock: mock.post(ANY, status_code=200, text='') provider = MythicBeastsProvider('test', {'unit.tests.': 'mypassword'}) plan = provider.plan(zone) changes = plan.changes generated_commands = [] for change in changes: generated_commands.extend( provider._compile_commands('ADD', change.new)) expected_commands = [ 'ADD unit.tests 60 ANAME alias.unit.tests.', 'ADD prawf-ns.unit.tests 300 NS alias.unit.tests.', 'ADD prawf-ns.unit.tests 300 NS alias2.unit.tests.', 'ADD prawf-a.unit.tests 60 A 1.2.3.4', 'ADD prawf-a.unit.tests 60 A 5.6.7.8', 'ADD prawf-aaaa.unit.tests 60 AAAA a:a::a', 'ADD prawf-aaaa.unit.tests 60 AAAA b:b::b', 'ADD prawf-aaaa.unit.tests 60 AAAA c:c::c:c', 'ADD prawf-txt.unit.tests 60 TXT prawf prawf dyma prawf', 'ADD prawf-txt2.unit.tests 60 TXT v=DKIM1; k=rsa; p=prawf', ] generated_commands.sort() expected_commands.sort() self.assertEquals(generated_commands, expected_commands) # Now test deletion existing = 'prawf-txt 300 TXT prawf prawf dyma prawf\n' \ 'prawf-txt2 300 TXT v=DKIM1; k=rsa; p=prawf\n' \ 'prawf-a 60 A 1.2.3.4' with requests_mock() as mock: mock.post(ANY, status_code=200, text=existing) wanted = Zone('unit.tests.', []) plan = provider.plan(wanted) changes = plan.changes generated_commands = [] for change in changes: generated_commands.extend( provider._compile_commands('DELETE', change.existing)) expected_commands = [ 'DELETE prawf-a.unit.tests 60 A 1.2.3.4', 'DELETE prawf-txt.unit.tests 300 TXT prawf prawf dyma prawf', 'DELETE prawf-txt2.unit.tests 300 TXT v=DKIM1; k=rsa; p=prawf', ] generated_commands.sort() expected_commands.sort() self.assertEquals(generated_commands, expected_commands) def test_fake_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 record(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('Passwords must be a dictionary', text_type(err.exception)) # 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', text_type(err.exception)) # 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 zone: 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)) # Record change failed with requests_mock() as mock: mock.post(ANY, status_code=200, text='') provider.populate(zone) zone.add_record( Record.new(zone, 'prawf', { 'ttl': 300, 'type': 'TXT', 'value': 'prawf', })) plan = provider.plan(zone) with requests_mock() as mock: mock.post(ANY, status_code=400, text='NADD 300 TXT prawf') with self.assertRaises(Exception) as err: provider.apply(plan) self.assertEquals( 'Mythic Beasts could not action command: unit.tests ' 'ADD prawf.unit.tests 300 TXT prawf', err.exception.message) # 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([c for c in plan.changes if isinstance(c, Update)])) self.assertEquals( 1, len([c for c in plan.changes if isinstance(c, Delete)])) self.assertEquals( 14, len([c for c in plan.changes if isinstance(c, Create)])) self.assertEquals(16, provider.apply(plan)) self.assertTrue(plan.exists)
class TestConstellixProvider(TestCase): expected = Zone('unit.tests.', []) source = YamlProvider('test', join(dirname(__file__), 'config')) source.populate(expected) # Our test suite differs a bit, add our NS and remove the simple one expected.add_record( Record.new( expected, 'under', { 'ttl': 3600, 'type': 'NS', 'values': [ 'ns1.unit.tests.', 'ns2.unit.tests.', ] })) # Add some ALIAS records expected.add_record( Record.new(expected, '', { 'ttl': 1800, 'type': 'ALIAS', 'value': 'aname.unit.tests.' })) for record in list(expected.records): if record.name == 'sub' and record._type == 'NS': expected._remove_record(record) break def test_populate(self): provider = ConstellixProvider('test', 'api', 'secret') # Bad auth with requests_mock() as mock: mock.get(ANY, status_code=401, text='{"errors": ["Unable to authenticate token"]}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('Unauthorized', text_type(ctx.exception)) # Bad request with requests_mock() as mock: mock.get(ANY, status_code=400, text='{"errors": ["\\"unittests\\" is not ' 'a valid domain name"]}') with self.assertRaises(Exception) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals('\n - "unittests" is not a valid domain name', text_type(ctx.exception)) # General error with requests_mock() as mock: mock.get(ANY, status_code=502, text='Things caught fire') with self.assertRaises(HTTPError) as ctx: zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(502, ctx.exception.response.status_code) # Non-existent zone doesn't populate anything with requests_mock() as mock: mock.get(ANY, status_code=404, text='<html><head></head><body></body></html>') zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(set(), zone.records) # No diffs == no changes with requests_mock() as mock: base = 'https://api.dns.constellix.com/v1/domains' with open('tests/fixtures/constellix-domains.json') as fh: mock.get('{}{}'.format(base, ''), text=fh.read()) with open('tests/fixtures/constellix-records.json') as fh: mock.get('{}{}'.format(base, '/123123/records'), text=fh.read()) zone = Zone('unit.tests.', []) provider.populate(zone) self.assertEquals(16, len(zone.records)) changes = self.expected.changes(zone, provider) self.assertEquals(0, len(changes)) # 2nd populate makes no network calls/all from cache again = Zone('unit.tests.', []) provider.populate(again) self.assertEquals(16, len(again.records)) # bust the cache del provider._zone_records[zone.name] def test_apply(self): provider = ConstellixProvider('test', 'api', 'secret') resp = Mock() resp.json = Mock() provider._client._request = Mock(return_value=resp) # non-existent domain, create everything resp.json.side_effect = [ [], # no domains returned during populate [{ 'id': 123123, 'name': 'unit.tests' }], # domain created in apply ] plan = provider.plan(self.expected) # No root NS, no ignored, no excluded, no unsupported n = len(self.expected.records) - 7 self.assertEquals(n, len(plan.changes)) self.assertEquals(n, provider.apply(plan)) provider._client._request.assert_has_calls([ # get all domains to build the cache call('GET', ''), # created the domain call('POST', '/', data={'names': ['unit.tests']}) ]) # These two checks are broken up so that ordering doesn't break things. # Python3 doesn't make the calls in a consistent order so different # things follow the GET / on different runs provider._client._request.assert_has_calls([ call('POST', '/123123/records/SRV', data={ 'roundRobin': [{ 'priority': 10, 'weight': 20, 'value': 'foo-1.unit.tests.', 'port': 30 }, { 'priority': 12, 'weight': 20, 'value': 'foo-2.unit.tests.', 'port': 30 }], 'name': '_srv._tcp', 'ttl': 600, }), ]) self.assertEquals(19, provider._client._request.call_count) provider._client._request.reset_mock() provider._client.records = Mock( return_value=[{ 'id': 11189897, 'type': 'A', 'name': 'www', 'ttl': 300, 'value': [ '1.2.3.4', '2.2.3.4', ] }, { 'id': 11189898, 'type': 'A', 'name': 'ttl', 'ttl': 600, 'value': ['3.2.3.4'] }, { 'id': 11189899, 'type': 'ALIAS', 'name': 'alias', 'ttl': 600, 'value': [{ 'value': 'aname.unit.tests.' }] }]) # Domain exists, we don't care about return resp.json.side_effect = ['{}'] wanted = Zone('unit.tests.', []) wanted.add_record( Record.new(wanted, 'ttl', { 'ttl': 300, 'type': 'A', 'value': '3.2.3.4' })) plan = provider.plan(wanted) self.assertEquals(3, len(plan.changes)) self.assertEquals(3, provider.apply(plan)) # recreate for update, and deletes for the 2 parts of the other provider._client._request.assert_has_calls([ call('POST', '/123123/records/A', data={ 'roundRobin': [{ 'value': '3.2.3.4' }], 'name': 'ttl', 'ttl': 300 }), call('DELETE', '/123123/records/A/11189897'), call('DELETE', '/123123/records/A/11189898'), call('DELETE', '/123123/records/ANAME/11189899') ], any_order=True)