Files
pic/tests/test_ddns_manager.py
T
roof aa1e5c41ec
Unit Tests / test (push) Successful in 12m6s
test: raise coverage 68.7% -> ~80.4%; add ~250 tests for new egress/DDNS/network paths
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>
2026-06-10 09:03:39 -04:00

965 lines
41 KiB
Python

"""Tests for DDNSManager and DDNS provider classes."""
import os
import sys
import threading
import time
import unittest
from unittest.mock import MagicMock, patch, call
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api'))
from ddns_manager import (
DDNSManager,
DDNSProvider,
DDNSError,
DDNSTokenExpired,
PicNgoDDNS,
CloudflareDDNS,
DuckDNSDDNS,
_get_public_ip,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_response(status_code=200, json_data=None, text=''):
"""Build a minimal requests.Response-like mock."""
resp = MagicMock()
resp.ok = (200 <= status_code < 300)
resp.status_code = status_code
resp.json.return_value = json_data or {}
resp.text = text
return resp
def _make_config_manager(ddns_cfg=None, domain_cfg=None):
"""Return a mock config_manager with a real configs dict."""
cm = MagicMock()
configs = {}
if ddns_cfg is not None:
configs['ddns'] = {k: v for k, v in ddns_cfg.items() if k != 'token'}
cm.configs = configs
# Token is stored outside cell_config.json via get/set_ddns_token
cm.get_ddns_token.return_value = (ddns_cfg or {}).get('token', '')
return cm
# ---------------------------------------------------------------------------
# PicNgoDDNS tests
# ---------------------------------------------------------------------------
class TestPicNgoDDNSRegister(unittest.TestCase):
"""PicNgoDDNS.register() calls the correct URL with the correct body."""
def test_register_calls_correct_url(self):
provider = PicNgoDDNS(api_base_url='https://ddns.example.com')
mock_resp = _make_response(200, json_data={'token': 'tok123', 'subdomain': 'alpha'})
with patch('requests.post', return_value=mock_resp) as mock_post:
result = provider.register('alpha', '1.2.3.4')
mock_post.assert_called_once()
args, kwargs = mock_post.call_args
self.assertEqual(args[0], 'https://ddns.example.com/api/v1/register')
self.assertEqual(kwargs['json'], {'name': 'alpha', 'ip': '1.2.3.4'})
self.assertEqual(result, {'token': 'tok123', 'subdomain': 'alpha'})
def test_register_raises_ddns_error_on_http_error(self):
provider = PicNgoDDNS()
mock_resp = _make_response(500, text='Internal Server Error')
with patch('requests.post', return_value=mock_resp):
with self.assertRaises(DDNSError):
provider.register('alpha', '1.2.3.4')
def test_register_no_token_in_header(self):
"""register() must NOT send an Authorization header (no token yet)."""
provider = PicNgoDDNS()
mock_resp = _make_response(200, json_data={'token': 't', 'subdomain': 'x'})
with patch('requests.post', return_value=mock_resp) as mock_post:
provider.register('x', '1.2.3.4')
_, kwargs = mock_post.call_args
self.assertNotIn('Authorization', kwargs.get('headers', {}))
def test_register_sends_otp_header_when_secret_configured(self):
"""register() sends X-Register-OTP when totp_secret is set."""
provider = PicNgoDDNS(totp_secret='JBSWY3DPEHPK3PXP')
mock_resp = _make_response(200, json_data={'token': 'tok', 'subdomain': 'x.pic.ngo'})
with patch('requests.post', return_value=mock_resp) as mock_post:
provider.register('x', '1.2.3.4')
_, kwargs = mock_post.call_args
self.assertIn('X-Register-OTP', kwargs.get('headers', {}))
otp = kwargs['headers']['X-Register-OTP']
self.assertEqual(len(otp), 6)
self.assertTrue(otp.isdigit())
def test_register_no_otp_header_without_secret(self):
"""register() omits X-Register-OTP when no TOTP secret is configured."""
provider = PicNgoDDNS()
mock_resp = _make_response(200, json_data={'token': 't', 'subdomain': 'x'})
with patch('requests.post', return_value=mock_resp) as mock_post:
provider.register('x', '1.2.3.4')
_, kwargs = mock_post.call_args
self.assertNotIn('X-Register-OTP', kwargs.get('headers', {}))
class TestPicNgoDDNSUpdate(unittest.TestCase):
"""PicNgoDDNS.update() sends token in the request body (DDNS server validates it there)."""
def test_update_sends_token_in_body(self):
provider = PicNgoDDNS(api_base_url='https://ddns.example.com')
mock_resp = _make_response(200)
with patch('requests.put', return_value=mock_resp) as mock_put:
result = provider.update('mytoken', '5.6.7.8')
mock_put.assert_called_once()
args, kwargs = mock_put.call_args
self.assertEqual(args[0], 'https://ddns.example.com/api/v1/update')
# Token must be in the JSON body — server validates it there, not in Authorization
self.assertEqual(kwargs['json'], {'ip': '5.6.7.8', 'token': 'mytoken'})
self.assertTrue(result)
def test_update_does_not_use_bearer_header(self):
"""Token must NOT be sent as Authorization: Bearer — server ignores it and returns 422."""
provider = PicNgoDDNS(api_base_url='https://ddns.example.com')
mock_resp = _make_response(200)
with patch('requests.put', return_value=mock_resp) as mock_put:
provider.update('mytoken', '1.2.3.4')
_, kwargs = mock_put.call_args
self.assertNotIn('Authorization', kwargs.get('headers', {}))
def test_update_raises_ddns_error_on_failure(self):
provider = PicNgoDDNS()
mock_resp = _make_response(403, text='Forbidden')
with patch('requests.put', return_value=mock_resp):
with self.assertRaises(DDNSError):
provider.update('badtoken', '1.2.3.4')
def test_update_raises_ddns_token_expired_on_401(self):
provider = PicNgoDDNS()
mock_resp = _make_response(401, text='Unauthorized')
with patch('requests.put', return_value=mock_resp):
with self.assertRaises(DDNSTokenExpired):
provider.update('expiredtoken', '1.2.3.4')
class TestPicNgoDDNSChallenges(unittest.TestCase):
"""PicNgoDDNS.dns_challenge_create/delete call correct endpoints."""
def test_dns_challenge_create_calls_post(self):
provider = PicNgoDDNS(api_base_url='https://ddns.example.com')
mock_resp = _make_response(200)
with patch('requests.post', return_value=mock_resp) as mock_post:
result = provider.dns_challenge_create('tok', '_acme.alpha.pic.ngo', 'abc123')
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', 'token': 'tok'})
self.assertEqual(kwargs['headers']['Authorization'], 'Bearer tok')
self.assertTrue(result)
def test_dns_challenge_delete_calls_delete(self):
provider = PicNgoDDNS(api_base_url='https://ddns.example.com')
mock_resp = _make_response(200)
with patch('requests.delete', return_value=mock_resp) as mock_del:
result = provider.dns_challenge_delete('tok', '_acme.alpha.pic.ngo')
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', 'token': 'tok'})
self.assertEqual(kwargs['headers']['Authorization'], 'Bearer tok')
self.assertTrue(result)
def test_dns_challenge_create_raises_on_error(self):
provider = PicNgoDDNS()
mock_resp = _make_response(500, text='error')
with patch('requests.post', return_value=mock_resp):
with self.assertRaises(DDNSError):
provider.dns_challenge_create('tok', 'fqdn', 'val')
def test_dns_challenge_delete_raises_on_error(self):
provider = PicNgoDDNS()
mock_resp = _make_response(404, text='not found')
with patch('requests.delete', return_value=mock_resp):
with self.assertRaises(DDNSError):
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
# ---------------------------------------------------------------------------
class TestGetProvider(unittest.TestCase):
"""DDNSManager.get_provider() returns the correct provider class."""
def test_returns_pic_ngo_provider(self):
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo'})
mgr = DDNSManager(config_manager=cm)
provider = mgr.get_provider()
self.assertIsInstance(provider, PicNgoDDNS)
def test_returns_none_when_no_ddns_config(self):
cm = _make_config_manager() # empty identity
mgr = DDNSManager(config_manager=cm)
provider = mgr.get_provider()
self.assertIsNone(provider)
def test_returns_none_when_no_provider_key(self):
cm = _make_config_manager(ddns_cfg={})
mgr = DDNSManager(config_manager=cm)
provider = mgr.get_provider()
self.assertIsNone(provider)
def test_returns_cloudflare_provider(self):
cm = _make_config_manager(ddns_cfg={
'provider': 'cloudflare',
'api_token': 'cf_tok',
'zone_id': 'zid',
})
mgr = DDNSManager(config_manager=cm)
provider = mgr.get_provider()
self.assertIsInstance(provider, CloudflareDDNS)
def test_returns_duckdns_provider(self):
cm = _make_config_manager(ddns_cfg={
'provider': 'duckdns',
'token': 'duck_tok',
'domain': 'mypic',
})
mgr = DDNSManager(config_manager=cm)
provider = mgr.get_provider()
self.assertIsInstance(provider, DuckDNSDDNS)
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)
with self.assertRaises(DDNSError) as ctx:
mgr.get_provider()
self.assertIn('not yet supported', str(ctx.exception))
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.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'})
mgr = DDNSManager(config_manager=cm)
provider = mgr.get_provider()
self.assertIsNone(provider)
def test_uses_custom_api_base_url(self):
cm = _make_config_manager(ddns_cfg={
'provider': 'pic_ngo',
'api_base_url': 'https://custom.example.com',
})
mgr = DDNSManager(config_manager=cm)
provider = mgr.get_provider()
self.assertIsInstance(provider, PicNgoDDNS)
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
# ---------------------------------------------------------------------------
class TestUpdateIp(unittest.TestCase):
"""DDNSManager.update_ip() calls provider.update() only when IP changed."""
def _make_manager_with_mock_provider(self, token='tok', last_ip=None):
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo', 'token': token})
mgr = DDNSManager(config_manager=cm)
mgr._last_ip = last_ip
mock_provider = MagicMock()
mock_provider.update.return_value = True
mgr.get_provider = MagicMock(return_value=mock_provider)
return mgr, mock_provider, cm
def test_update_when_ip_changed(self):
mgr, mock_provider, _ = self._make_manager_with_mock_provider(last_ip='1.1.1.1')
with patch('ddns_manager._get_public_ip', return_value='2.2.2.2'):
mgr.update_ip()
mock_provider.update.assert_called_once_with('tok', '2.2.2.2')
self.assertEqual(mgr._last_ip, '2.2.2.2')
def test_skips_update_when_ip_unchanged(self):
mgr, mock_provider, _ = self._make_manager_with_mock_provider(last_ip='3.3.3.3')
with patch('ddns_manager._get_public_ip', return_value='3.3.3.3'):
mgr.update_ip()
mock_provider.update.assert_not_called()
self.assertEqual(mgr._last_ip, '3.3.3.3')
def test_skips_update_when_no_provider(self):
cm = _make_config_manager()
mgr = DDNSManager(config_manager=cm)
mgr._last_ip = None
# Should not raise, just silently skip
with patch('ddns_manager._get_public_ip', return_value='1.2.3.4'):
mgr.update_ip()
def test_skips_update_when_ip_unreachable(self):
mgr, mock_provider, _ = self._make_manager_with_mock_provider(last_ip=None)
with patch('ddns_manager._get_public_ip', return_value=None):
mgr.update_ip()
mock_provider.update.assert_not_called()
def test_last_ip_not_updated_when_provider_returns_false(self):
mgr, mock_provider, _ = self._make_manager_with_mock_provider(last_ip='1.1.1.1')
mock_provider.update.return_value = False
with patch('ddns_manager._get_public_ip', return_value='9.9.9.9'):
mgr.update_ip()
# IP should not be cached when provider says False
self.assertEqual(mgr._last_ip, '1.1.1.1')
def test_ddns_error_is_caught_not_propagated(self):
mgr, mock_provider, _ = self._make_manager_with_mock_provider(last_ip='1.1.1.1')
mock_provider.update.side_effect = DDNSError("server error")
with patch('ddns_manager._get_public_ip', return_value='5.5.5.5'):
# Should not raise
mgr.update_ip()
def test_no_token_triggers_registration_and_fires_identity_changed(self):
"""When no token exists, update_ip() registers immediately and fires IDENTITY_CHANGED."""
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo'})
cm.get_ddns_token.return_value = ''
cm.get_identity.return_value = {'cell_name': 'mytest'}
mock_sbus = MagicMock()
mgr = DDNSManager(config_manager=cm, service_bus=mock_sbus)
mgr._last_ip = None
mock_provider = MagicMock()
mock_provider.register.return_value = {'token': 'new_tok', 'subdomain': 'mytest.pic.ngo'}
mgr.get_provider = MagicMock(return_value=mock_provider)
with patch('ddns_manager._get_public_ip', return_value='1.2.3.4'):
mgr.update_ip()
mock_provider.register.assert_called_once_with('mytest', '1.2.3.4')
mock_provider.update.assert_not_called()
self.assertEqual(mgr._last_ip, '1.2.3.4')
mock_sbus.publish_event.assert_called_once()
# ---------------------------------------------------------------------------
# DDNSManager.register() tests
# ---------------------------------------------------------------------------
class TestRegister(unittest.TestCase):
def test_register_stores_token_in_ddns_config(self):
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo'})
mgr = DDNSManager(config_manager=cm)
mock_provider = MagicMock()
mock_provider.register.return_value = {'token': 'new_tok', 'subdomain': 'alpha.pic.ngo'}
mgr.get_provider = MagicMock(return_value=mock_provider)
result = mgr.register('alpha', '1.2.3.4')
self.assertEqual(result['token'], 'new_tok')
# Token stored via set_ddns_token (not embedded in cell_config.json)
cm.set_ddns_token.assert_called_once_with('new_tok')
# Subdomain saved to _identity.domain_name
cm.set_identity_field.assert_called_once_with('domain_name', 'alpha.pic.ngo')
def test_register_fetches_public_ip_when_empty(self):
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo'})
mgr = DDNSManager(config_manager=cm)
mock_provider = MagicMock()
mock_provider.register.return_value = {'token': 't', 'subdomain': 'alpha.pic.ngo'}
mgr.get_provider = MagicMock(return_value=mock_provider)
with patch('ddns_manager._get_public_ip', return_value='5.6.7.8') as mock_ip:
mgr.register('alpha', '')
mock_ip.assert_called_once()
mock_provider.register.assert_called_once_with('alpha', '5.6.7.8')
def test_register_uses_provided_ip_without_fetching(self):
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo'})
mgr = DDNSManager(config_manager=cm)
mock_provider = MagicMock()
mock_provider.register.return_value = {'token': 't', 'subdomain': 'alpha.pic.ngo'}
mgr.get_provider = MagicMock(return_value=mock_provider)
with patch('ddns_manager._get_public_ip') as mock_ip:
mgr.register('alpha', '1.2.3.4')
mock_ip.assert_not_called()
mock_provider.register.assert_called_once_with('alpha', '1.2.3.4')
def test_register_releases_old_name_when_changing(self):
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo', 'token': 'old_tok'})
cm.get_identity.return_value = {'domain_name': 'oldname.pic.ngo'}
mgr = DDNSManager(config_manager=cm)
mock_provider = MagicMock()
mock_provider.register.return_value = {'token': 'new_tok', 'subdomain': 'newname.pic.ngo'}
mgr.get_provider = MagicMock(return_value=mock_provider)
mgr.register('newname', '1.2.3.4')
mock_provider.release.assert_called_once_with('old_tok')
mock_provider.register.assert_called_once_with('newname', '1.2.3.4')
def test_register_skips_release_when_name_unchanged(self):
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo', 'token': 'tok'})
cm.get_identity.return_value = {'domain_name': 'alpha.pic.ngo'}
mgr = DDNSManager(config_manager=cm)
mock_provider = MagicMock()
mock_provider.register.return_value = {'token': 'tok2', 'subdomain': 'alpha.pic.ngo'}
mgr.get_provider = MagicMock(return_value=mock_provider)
mgr.register('alpha', '1.2.3.4')
mock_provider.release.assert_not_called()
def test_register_skips_release_when_no_old_token(self):
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo'})
cm.get_identity.return_value = {'domain_name': 'oldname.pic.ngo'}
mgr = DDNSManager(config_manager=cm)
mock_provider = MagicMock()
mock_provider.register.return_value = {'token': 'new_tok', 'subdomain': 'newname.pic.ngo'}
mgr.get_provider = MagicMock(return_value=mock_provider)
mgr.register('newname', '1.2.3.4')
mock_provider.release.assert_not_called()
def test_register_continues_if_release_fails(self):
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo', 'token': 'old_tok'})
cm.get_identity.return_value = {'domain_name': 'oldname.pic.ngo'}
mgr = DDNSManager(config_manager=cm)
mock_provider = MagicMock()
mock_provider.release.side_effect = DDNSError("server down")
mock_provider.register.return_value = {'token': 'new_tok', 'subdomain': 'newname.pic.ngo'}
mgr.get_provider = MagicMock(return_value=mock_provider)
result = mgr.register('newname', '1.2.3.4')
self.assertEqual(result['token'], 'new_tok')
mock_provider.register.assert_called_once()
def test_register_raises_when_no_provider(self):
cm = _make_config_manager()
mgr = DDNSManager(config_manager=cm)
with self.assertRaises(DDNSError):
mgr.register('alpha', '1.2.3.4')
# ---------------------------------------------------------------------------
# DDNSManager.dns_challenge_create/delete delegation tests
# ---------------------------------------------------------------------------
class TestDnsChallenges(unittest.TestCase):
def _make_manager(self):
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo', 'token': 'tok'})
mgr = DDNSManager(config_manager=cm)
mock_provider = MagicMock()
mock_provider.dns_challenge_create.return_value = True
mock_provider.dns_challenge_delete.return_value = True
mgr.get_provider = MagicMock(return_value=mock_provider)
return mgr, mock_provider
def test_dns_challenge_create_delegates(self):
mgr, mock_provider = self._make_manager()
mgr.dns_challenge_create('_acme.alpha.pic.ngo', 'val123')
mock_provider.dns_challenge_create.assert_called_once_with(
'tok', '_acme.alpha.pic.ngo', 'val123'
)
def test_dns_challenge_delete_delegates(self):
mgr, mock_provider = self._make_manager()
mgr.dns_challenge_delete('_acme.alpha.pic.ngo')
mock_provider.dns_challenge_delete.assert_called_once_with(
'tok', '_acme.alpha.pic.ngo'
)
def test_dns_challenge_create_raises_when_no_provider(self):
cm = _make_config_manager()
mgr = DDNSManager(config_manager=cm)
with self.assertRaises(DDNSError):
mgr.dns_challenge_create('fqdn', 'val')
def test_dns_challenge_delete_raises_when_no_provider(self):
cm = _make_config_manager()
mgr = DDNSManager(config_manager=cm)
with self.assertRaises(DDNSError):
mgr.dns_challenge_delete('fqdn')
# ---------------------------------------------------------------------------
# Background heartbeat thread tests
# ---------------------------------------------------------------------------
class TestHeartbeat(unittest.TestCase):
"""Background heartbeat thread starts, runs, and can be stopped cleanly."""
def test_heartbeat_starts(self):
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo', 'token': 'tok'})
mgr = DDNSManager(config_manager=cm)
mgr.update_ip = MagicMock() # avoid real network
mgr.start_heartbeat()
try:
self.assertIsNotNone(mgr._heartbeat_thread)
self.assertTrue(mgr._heartbeat_thread.is_alive())
finally:
mgr.stop_heartbeat()
def test_heartbeat_can_be_stopped(self):
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo', 'token': 'tok'})
mgr = DDNSManager(config_manager=cm)
mgr.update_ip = MagicMock()
mgr.start_heartbeat()
mgr.stop_heartbeat()
# Thread should be dead after stop
if mgr._heartbeat_thread is not None:
self.assertFalse(mgr._heartbeat_thread.is_alive())
def test_start_heartbeat_is_idempotent(self):
"""Calling start_heartbeat() twice should not create a second thread."""
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo', 'token': 'tok'})
mgr = DDNSManager(config_manager=cm)
mgr.update_ip = MagicMock()
mgr.start_heartbeat()
thread1 = mgr._heartbeat_thread
mgr.start_heartbeat()
thread2 = mgr._heartbeat_thread
try:
self.assertIs(thread1, thread2)
finally:
mgr.stop_heartbeat()
def test_heartbeat_calls_update_ip(self):
"""Heartbeat loop must invoke update_ip() at least once."""
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo', 'token': 'tok'})
mgr = DDNSManager(config_manager=cm)
called_event = threading.Event()
def _fake_update_ip():
called_event.set()
mgr.update_ip = _fake_update_ip
mgr.start_heartbeat()
called = called_event.wait(timeout=3)
mgr.stop_heartbeat()
self.assertTrue(called, "update_ip() was not called within 3 seconds")
def test_heartbeat_survives_exception_in_update_ip(self):
"""An exception in update_ip() must not crash the heartbeat thread."""
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo', 'token': 'tok'})
mgr = DDNSManager(config_manager=cm)
call_count = [0]
survived_event = threading.Event()
def _flaky_update_ip():
call_count[0] += 1
if call_count[0] == 1:
raise RuntimeError("transient failure")
survived_event.set()
mgr.update_ip = _flaky_update_ip
# Patch the interval to be 0 so the loop spins immediately
import ddns_manager as _dm
original_interval = _dm._HEARTBEAT_INTERVAL
_dm._HEARTBEAT_INTERVAL = 0
try:
mgr.start_heartbeat()
survived = survived_event.wait(timeout=5)
mgr.stop_heartbeat()
self.assertTrue(survived, "Thread did not survive exception in update_ip()")
finally:
_dm._HEARTBEAT_INTERVAL = original_interval
# ---------------------------------------------------------------------------
# get_status() and test_connectivity() smoke tests
# ---------------------------------------------------------------------------
class TestGetStatus(unittest.TestCase):
def test_get_status_returns_dict(self):
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo'})
mgr = DDNSManager(config_manager=cm)
status = mgr.get_status()
self.assertIn('service', status)
self.assertEqual(status['service'], 'ddns')
self.assertIn('provider', status)
self.assertEqual(status['provider'], 'pic_ngo')
def test_get_status_no_config(self):
cm = _make_config_manager()
mgr = DDNSManager(config_manager=cm)
status = mgr.get_status()
self.assertIsNone(status['provider'])
def test_test_connectivity_no_provider(self):
cm = _make_config_manager()
mgr = DDNSManager(config_manager=cm)
result = mgr.test_connectivity()
self.assertFalse(result['success'])
def test_test_connectivity_with_provider(self):
cm = _make_config_manager(ddns_cfg={'provider': 'pic_ngo'})
mgr = DDNSManager(config_manager=cm)
with patch('ddns_manager._get_public_ip', return_value='1.2.3.4'):
result = mgr.test_connectivity()
self.assertTrue(result['success'])
self.assertEqual(result['public_ip'], '1.2.3.4')
# ---------------------------------------------------------------------------
# _get_public_ip helper tests
# ---------------------------------------------------------------------------
class TestGetPublicIp(unittest.TestCase):
def test_returns_ip_on_success(self):
mock_resp = MagicMock()
mock_resp.ok = True
mock_resp.text = ' 1.2.3.4 '
with patch('requests.get', return_value=mock_resp):
result = _get_public_ip()
self.assertEqual(result, '1.2.3.4')
def test_returns_none_on_failure(self):
with patch('requests.get', side_effect=Exception('network error')):
result = _get_public_ip()
self.assertIsNone(result)
def test_returns_none_on_non_ok_response(self):
mock_resp = MagicMock()
mock_resp.ok = False
with patch('requests.get', return_value=mock_resp):
result = _get_public_ip()
self.assertIsNone(result)
if __name__ == '__main__':
unittest.main()