test: raise coverage 68.7% -> ~80.4%; add ~250 tests for new egress/DDNS/network paths
Unit Tests / test (push) Successful in 12m6s
Unit Tests / test (push) Successful in 12m6s
Coverage was below acceptable levels and several newly-added code paths (sshuttle egress, proxy egress, DDNS provider stubs, DNS overview route, peer-registry provisioning) had zero test coverage. ~250 new unit tests are added across 16 new test files. Existing test files are updated to match refactored interfaces (DHCP removed, constants introduced, network_manager restructured). .coveragerc is added to pin the source mapping and the 70% floor so regressions are caught at commit time. tests/test_enhanced_api.py was previously living in api/ (wrong location) and is moved to tests/ where it belongs. Integration test files are updated to remove references to DHCP endpoints and add coverage for the new DNS overview and DDNS sync endpoints. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+326
-9
@@ -17,8 +17,6 @@ from ddns_manager import (
|
||||
PicNgoDDNS,
|
||||
CloudflareDDNS,
|
||||
DuckDNSDDNS,
|
||||
NoIPDDNS,
|
||||
FreeDNSDDNS,
|
||||
_get_public_ip,
|
||||
)
|
||||
|
||||
@@ -155,7 +153,8 @@ class TestPicNgoDDNSChallenges(unittest.TestCase):
|
||||
mock_post.assert_called_once()
|
||||
args, kwargs = mock_post.call_args
|
||||
self.assertEqual(args[0], 'https://ddns.example.com/api/v1/dns-challenge')
|
||||
self.assertEqual(kwargs['json'], {'fqdn': '_acme.alpha.pic.ngo', 'value': 'abc123'})
|
||||
self.assertEqual(kwargs['json'],
|
||||
{'fqdn': '_acme.alpha.pic.ngo', 'value': 'abc123', 'token': 'tok'})
|
||||
self.assertEqual(kwargs['headers']['Authorization'], 'Bearer tok')
|
||||
self.assertTrue(result)
|
||||
|
||||
@@ -167,7 +166,7 @@ class TestPicNgoDDNSChallenges(unittest.TestCase):
|
||||
mock_del.assert_called_once()
|
||||
args, kwargs = mock_del.call_args
|
||||
self.assertEqual(args[0], 'https://ddns.example.com/api/v1/dns-challenge')
|
||||
self.assertEqual(kwargs['json'], {'fqdn': '_acme.alpha.pic.ngo'})
|
||||
self.assertEqual(kwargs['json'], {'fqdn': '_acme.alpha.pic.ngo', 'token': 'tok'})
|
||||
self.assertEqual(kwargs['headers']['Authorization'], 'Bearer tok')
|
||||
self.assertTrue(result)
|
||||
|
||||
@@ -186,6 +185,236 @@ class TestPicNgoDDNSChallenges(unittest.TestCase):
|
||||
provider.dns_challenge_delete('tok', 'fqdn')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CloudflareDDNS tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCloudflareDDNSUpdate(unittest.TestCase):
|
||||
"""CloudflareDDNS.update() looks up the A record id, then PATCHes that record."""
|
||||
|
||||
def _provider(self, domain='cell.example.com'):
|
||||
return CloudflareDDNS(api_token='cf_tok', zone_id='zid123', domain=domain)
|
||||
|
||||
def test_update_gets_record_id_then_patches_it(self):
|
||||
provider = self._provider()
|
||||
get_resp = _make_response(200, json_data={'result': [{'id': 'rec42'}]})
|
||||
patch_resp = _make_response(200)
|
||||
with patch('requests.get', return_value=get_resp) as mock_get, \
|
||||
patch('requests.patch', return_value=patch_resp) as mock_patch:
|
||||
result = provider.update('unused-token', '5.6.7.8')
|
||||
|
||||
self.assertTrue(result)
|
||||
# Lookup: GET the dns_records collection filtered by type+name
|
||||
mock_get.assert_called_once()
|
||||
get_args, get_kwargs = mock_get.call_args
|
||||
self.assertEqual(
|
||||
get_args[0],
|
||||
'https://api.cloudflare.com/client/v4/zones/zid123/dns_records',
|
||||
)
|
||||
self.assertEqual(get_kwargs['params'], {'type': 'A', 'name': 'cell.example.com'})
|
||||
# Update: PATCH the individual record with the Cloudflare payload shape
|
||||
mock_patch.assert_called_once()
|
||||
patch_args, patch_kwargs = mock_patch.call_args
|
||||
self.assertEqual(
|
||||
patch_args[0],
|
||||
'https://api.cloudflare.com/client/v4/zones/zid123/dns_records/rec42',
|
||||
)
|
||||
self.assertEqual(
|
||||
patch_kwargs['json'],
|
||||
{'type': 'A', 'name': 'cell.example.com', 'content': '5.6.7.8'},
|
||||
)
|
||||
self.assertEqual(
|
||||
patch_kwargs['headers']['Authorization'], 'Bearer cf_tok'
|
||||
)
|
||||
|
||||
def test_update_returns_false_when_record_not_found(self):
|
||||
provider = self._provider()
|
||||
get_resp = _make_response(200, json_data={'result': []})
|
||||
with patch('requests.get', return_value=get_resp), \
|
||||
patch('requests.patch') as mock_patch:
|
||||
result = provider.update('tok', '1.2.3.4')
|
||||
self.assertFalse(result)
|
||||
mock_patch.assert_not_called()
|
||||
|
||||
def test_update_returns_false_when_lookup_fails(self):
|
||||
provider = self._provider()
|
||||
get_resp = _make_response(403, text='forbidden')
|
||||
with patch('requests.get', return_value=get_resp), \
|
||||
patch('requests.patch') as mock_patch:
|
||||
result = provider.update('tok', '1.2.3.4')
|
||||
self.assertFalse(result)
|
||||
mock_patch.assert_not_called()
|
||||
|
||||
def test_update_returns_false_when_patch_fails(self):
|
||||
provider = self._provider()
|
||||
get_resp = _make_response(200, json_data={'result': [{'id': 'rec1'}]})
|
||||
patch_resp = _make_response(500, text='server error')
|
||||
with patch('requests.get', return_value=get_resp), \
|
||||
patch('requests.patch', return_value=patch_resp):
|
||||
result = provider.update('tok', '1.2.3.4')
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_update_returns_false_without_domain(self):
|
||||
provider = self._provider(domain='')
|
||||
with patch('requests.get') as mock_get:
|
||||
result = provider.update('tok', '1.2.3.4')
|
||||
self.assertFalse(result)
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
class TestCloudflareDDNSSyncServiceRecords(unittest.TestCase):
|
||||
"""CloudflareDDNS.sync_service_records() ensures one A record per name."""
|
||||
|
||||
def _provider(self, domain='cell.example.com'):
|
||||
return CloudflareDDNS(api_token='cf_tok', zone_id='zid123', domain=domain)
|
||||
|
||||
def test_creates_missing_record_with_post(self):
|
||||
provider = self._provider()
|
||||
get_resp = _make_response(200, json_data={'result': []})
|
||||
post_resp = _make_response(200)
|
||||
with patch('requests.get', return_value=get_resp), \
|
||||
patch('requests.post', return_value=post_resp) as mock_post, \
|
||||
patch('requests.patch') as mock_patch:
|
||||
result = provider.sync_service_records(['mail.cell.example.com'], '9.9.9.9')
|
||||
self.assertTrue(result['success'])
|
||||
self.assertIn('cell.example.com', result['synced'])
|
||||
self.assertIn('mail.cell.example.com', result['synced'])
|
||||
self.assertEqual(result['failed'], [])
|
||||
mock_patch.assert_not_called()
|
||||
# apex + one subdomain = 2 POSTs (both missing)
|
||||
self.assertEqual(mock_post.call_count, 2)
|
||||
_, kwargs = mock_post.call_args
|
||||
self.assertEqual(kwargs['json']['type'], 'A')
|
||||
self.assertEqual(kwargs['json']['content'], '9.9.9.9')
|
||||
|
||||
def test_updates_existing_record_with_patch(self):
|
||||
provider = self._provider()
|
||||
get_resp = _make_response(200, json_data={'result': [{'id': 'rec7'}]})
|
||||
patch_resp = _make_response(200)
|
||||
with patch('requests.get', return_value=get_resp), \
|
||||
patch('requests.patch', return_value=patch_resp) as mock_patch, \
|
||||
patch('requests.post') as mock_post:
|
||||
result = provider.sync_service_records(['mail.cell.example.com'], '9.9.9.9')
|
||||
self.assertTrue(result['success'])
|
||||
mock_post.assert_not_called()
|
||||
self.assertEqual(mock_patch.call_count, 2)
|
||||
patch_args, _ = mock_patch.call_args
|
||||
self.assertIn('/dns_records/rec7', patch_args[0])
|
||||
|
||||
def test_reports_failure_when_write_fails(self):
|
||||
provider = self._provider()
|
||||
get_resp = _make_response(200, json_data={'result': []})
|
||||
post_resp = _make_response(500, text='server error')
|
||||
with patch('requests.get', return_value=get_resp), \
|
||||
patch('requests.post', return_value=post_resp):
|
||||
result = provider.sync_service_records(['mail.cell.example.com'], '9.9.9.9')
|
||||
self.assertFalse(result['success'])
|
||||
self.assertEqual(set(result['failed']),
|
||||
{'cell.example.com', 'mail.cell.example.com'})
|
||||
|
||||
def test_handles_lookup_error_as_failure(self):
|
||||
provider = self._provider()
|
||||
get_resp = _make_response(403, text='forbidden')
|
||||
with patch('requests.get', return_value=get_resp), \
|
||||
patch('requests.post') as mock_post, \
|
||||
patch('requests.patch') as mock_patch:
|
||||
result = provider.sync_service_records([], '9.9.9.9')
|
||||
self.assertFalse(result['success'])
|
||||
self.assertIn('cell.example.com', result['failed'])
|
||||
mock_post.assert_not_called()
|
||||
mock_patch.assert_not_called()
|
||||
|
||||
def test_no_domain_returns_unsuccessful(self):
|
||||
provider = self._provider(domain='')
|
||||
with patch('requests.get') as mock_get:
|
||||
result = provider.sync_service_records(['x.example.com'], '9.9.9.9')
|
||||
self.assertFalse(result['success'])
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_dedupes_apex_in_subdomains(self):
|
||||
provider = self._provider()
|
||||
get_resp = _make_response(200, json_data={'result': []})
|
||||
post_resp = _make_response(200)
|
||||
with patch('requests.get', return_value=get_resp), \
|
||||
patch('requests.post', return_value=post_resp) as mock_post:
|
||||
result = provider.sync_service_records(['cell.example.com'], '9.9.9.9')
|
||||
# apex passed again as a subdomain must not double-write
|
||||
self.assertEqual(result['synced'], ['cell.example.com'])
|
||||
self.assertEqual(mock_post.call_count, 1)
|
||||
|
||||
|
||||
class TestCloudflareDDNSChallenges(unittest.TestCase):
|
||||
"""CloudflareDDNS DNS-01 challenge record creation and deletion."""
|
||||
|
||||
def _provider(self):
|
||||
return CloudflareDDNS(api_token='cf_tok', zone_id='zid123', domain='cell.example.com')
|
||||
|
||||
def test_dns_challenge_create_posts_txt_record(self):
|
||||
provider = self._provider()
|
||||
post_resp = _make_response(200)
|
||||
with patch('requests.post', return_value=post_resp) as mock_post:
|
||||
result = provider.dns_challenge_create('tok', '_acme.cell.example.com', 'val')
|
||||
self.assertTrue(result)
|
||||
_, kwargs = mock_post.call_args
|
||||
self.assertEqual(kwargs['json']['type'], 'TXT')
|
||||
self.assertEqual(kwargs['json']['name'], '_acme.cell.example.com')
|
||||
self.assertEqual(kwargs['json']['content'], 'val')
|
||||
|
||||
def test_dns_challenge_delete_deletes_record_by_id(self):
|
||||
provider = self._provider()
|
||||
get_resp = _make_response(200, json_data={'result': [{'id': 'txt9'}]})
|
||||
del_resp = _make_response(200)
|
||||
with patch('requests.get', return_value=get_resp) as mock_get, \
|
||||
patch('requests.delete', return_value=del_resp) as mock_del:
|
||||
result = provider.dns_challenge_delete('tok', '_acme.cell.example.com')
|
||||
self.assertTrue(result)
|
||||
_, get_kwargs = mock_get.call_args
|
||||
self.assertEqual(get_kwargs['params'], {'type': 'TXT', 'name': '_acme.cell.example.com'})
|
||||
del_args, _ = mock_del.call_args
|
||||
self.assertEqual(
|
||||
del_args[0],
|
||||
'https://api.cloudflare.com/client/v4/zones/zid123/dns_records/txt9',
|
||||
)
|
||||
|
||||
def test_dns_challenge_delete_deletes_all_matching_records(self):
|
||||
provider = self._provider()
|
||||
get_resp = _make_response(200, json_data={'result': [{'id': 'a'}, {'id': 'b'}]})
|
||||
del_resp = _make_response(200)
|
||||
with patch('requests.get', return_value=get_resp), \
|
||||
patch('requests.delete', return_value=del_resp) as mock_del:
|
||||
result = provider.dns_challenge_delete('tok', '_acme.cell.example.com')
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(mock_del.call_count, 2)
|
||||
|
||||
def test_dns_challenge_delete_returns_false_when_no_record(self):
|
||||
"""Must NOT pretend success when there is nothing to delete."""
|
||||
provider = self._provider()
|
||||
get_resp = _make_response(200, json_data={'result': []})
|
||||
with patch('requests.get', return_value=get_resp), \
|
||||
patch('requests.delete') as mock_del:
|
||||
result = provider.dns_challenge_delete('tok', '_acme.cell.example.com')
|
||||
self.assertFalse(result)
|
||||
mock_del.assert_not_called()
|
||||
|
||||
def test_dns_challenge_delete_returns_false_when_delete_fails(self):
|
||||
provider = self._provider()
|
||||
get_resp = _make_response(200, json_data={'result': [{'id': 'txt9'}]})
|
||||
del_resp = _make_response(500, text='error')
|
||||
with patch('requests.get', return_value=get_resp), \
|
||||
patch('requests.delete', return_value=del_resp):
|
||||
result = provider.dns_challenge_delete('tok', '_acme.cell.example.com')
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_dns_challenge_delete_returns_false_when_lookup_fails(self):
|
||||
provider = self._provider()
|
||||
get_resp = _make_response(401, text='unauthorized')
|
||||
with patch('requests.get', return_value=get_resp), \
|
||||
patch('requests.delete') as mock_del:
|
||||
result = provider.dns_challenge_delete('tok', '_acme.cell.example.com')
|
||||
self.assertFalse(result)
|
||||
mock_del.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DDNSManager.get_provider() tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -231,17 +460,51 @@ class TestGetProvider(unittest.TestCase):
|
||||
provider = mgr.get_provider()
|
||||
self.assertIsInstance(provider, DuckDNSDDNS)
|
||||
|
||||
def test_returns_noip_provider(self):
|
||||
def test_noip_provider_rejected(self):
|
||||
"""'noip' is not yet supported — get_provider() must fail loudly."""
|
||||
cm = _make_config_manager(ddns_cfg={'provider': 'noip'})
|
||||
mgr = DDNSManager(config_manager=cm)
|
||||
provider = mgr.get_provider()
|
||||
self.assertIsInstance(provider, NoIPDDNS)
|
||||
with self.assertRaises(DDNSError) as ctx:
|
||||
mgr.get_provider()
|
||||
self.assertIn('not yet supported', str(ctx.exception))
|
||||
|
||||
def test_returns_freedns_provider(self):
|
||||
def test_freedns_provider_rejected(self):
|
||||
"""'freedns' is not yet supported — get_provider() must fail loudly."""
|
||||
cm = _make_config_manager(ddns_cfg={'provider': 'freedns'})
|
||||
mgr = DDNSManager(config_manager=cm)
|
||||
with self.assertRaises(DDNSError) as ctx:
|
||||
mgr.get_provider()
|
||||
self.assertIn('not yet supported', str(ctx.exception))
|
||||
|
||||
def test_test_connectivity_reports_unsupported_provider(self):
|
||||
"""test_connectivity() must not raise for unsupported providers."""
|
||||
cm = _make_config_manager(ddns_cfg={'provider': 'noip'})
|
||||
mgr = DDNSManager(config_manager=cm)
|
||||
result = mgr.test_connectivity()
|
||||
self.assertFalse(result['success'])
|
||||
self.assertIn('not yet supported', result['reason'])
|
||||
|
||||
def test_cloudflare_provider_gets_domain_from_config(self):
|
||||
cm = _make_config_manager(ddns_cfg={
|
||||
'provider': 'cloudflare',
|
||||
'api_token': 'cf_tok',
|
||||
'zone_id': 'zid',
|
||||
'domain': 'cell.example.com',
|
||||
})
|
||||
mgr = DDNSManager(config_manager=cm)
|
||||
provider = mgr.get_provider()
|
||||
self.assertIsInstance(provider, FreeDNSDDNS)
|
||||
self.assertEqual(provider.domain, 'cell.example.com')
|
||||
|
||||
def test_cloudflare_provider_falls_back_to_identity_domain(self):
|
||||
cm = _make_config_manager(ddns_cfg={
|
||||
'provider': 'cloudflare',
|
||||
'api_token': 'cf_tok',
|
||||
'zone_id': 'zid',
|
||||
})
|
||||
cm.get_identity.return_value = {'domain_name': 'ident.example.com'}
|
||||
mgr = DDNSManager(config_manager=cm)
|
||||
provider = mgr.get_provider()
|
||||
self.assertEqual(provider.domain, 'ident.example.com')
|
||||
|
||||
def test_returns_none_for_unknown_provider(self):
|
||||
cm = _make_config_manager(ddns_cfg={'provider': 'nonexistent'})
|
||||
@@ -260,6 +523,60 @@ class TestGetProvider(unittest.TestCase):
|
||||
self.assertEqual(provider.api_base_url, 'https://custom.example.com')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DDNSManager.sync_service_records() tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestManagerSyncServiceRecords(unittest.TestCase):
|
||||
"""DDNSManager.sync_service_records builds names and delegates to the provider."""
|
||||
|
||||
def _manager(self, routes):
|
||||
cm = _make_config_manager(ddns_cfg={'provider': 'cloudflare'})
|
||||
cm.get_effective_domain.return_value = 'cell.example.com'
|
||||
registry = MagicMock()
|
||||
registry.get_caddy_routes.return_value = routes
|
||||
mgr = DDNSManager(config_manager=cm, service_registry=registry)
|
||||
return mgr
|
||||
|
||||
def test_delegates_to_provider_with_fqdns(self):
|
||||
mgr = self._manager([
|
||||
{'subdomain': 'mail', 'extra_subdomains': []},
|
||||
{'subdomain': 'cal', 'extra_subdomains': ['dav']},
|
||||
])
|
||||
provider = MagicMock()
|
||||
provider.sync_service_records.return_value = {'success': True, 'synced': [], 'failed': []}
|
||||
mgr.get_provider = MagicMock(return_value=provider)
|
||||
with patch('ddns_manager._get_public_ip', return_value='7.7.7.7'):
|
||||
result = mgr.sync_service_records()
|
||||
self.assertTrue(result['success'])
|
||||
names, ip = provider.sync_service_records.call_args[0]
|
||||
self.assertEqual(ip, '7.7.7.7')
|
||||
self.assertIn('mail.cell.example.com', names)
|
||||
self.assertIn('cal.cell.example.com', names)
|
||||
self.assertIn('dav.cell.example.com', names)
|
||||
|
||||
def test_raises_when_no_provider(self):
|
||||
mgr = self._manager([])
|
||||
mgr.get_provider = MagicMock(return_value=None)
|
||||
with self.assertRaises(DDNSError):
|
||||
mgr.sync_service_records()
|
||||
|
||||
def test_raises_when_provider_lacks_support(self):
|
||||
mgr = self._manager([])
|
||||
provider = MagicMock(spec=['update', 'register'])
|
||||
mgr.get_provider = MagicMock(return_value=provider)
|
||||
with self.assertRaises(DDNSError):
|
||||
mgr.sync_service_records()
|
||||
|
||||
def test_raises_when_no_public_ip(self):
|
||||
mgr = self._manager([])
|
||||
provider = MagicMock()
|
||||
mgr.get_provider = MagicMock(return_value=provider)
|
||||
with patch('ddns_manager._get_public_ip', return_value=None):
|
||||
with self.assertRaises(DDNSError):
|
||||
mgr.sync_service_records()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DDNSManager.update_ip() tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user