Files
pic/tests/test_ddns_endpoints.py
T
roof 0b31d02f10
Unit Tests / test (push) Successful in 15m26s
feat: DDNS self-healing heartbeat + manual re-register endpoint
- DDNSTokenExpired exception triggers auto re-register in update_ip()
  so cells recover silently after a DDNS DB reset
- POST /api/ddns/register lets the user force re-registration from Settings
- Re-register button in Settings → External Domain & DDNS (pic_ngo only)
- 3 new tests covering register endpoint: wrong provider, missing name, success

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:05:27 -04:00

174 lines
7.1 KiB
Python

"""Tests for GET /api/ddns/check/<name> and PUT /api/ddns."""
import json
import sys
import os
import unittest
from unittest.mock import patch, MagicMock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api'))
def _make_client():
from app import app
app.config['TESTING'] = True
return app.test_client()
class TestDdnsCheckName(unittest.TestCase):
def setUp(self):
self.client = _make_client()
def _get(self, name):
return self.client.get(f'/api/ddns/check/{name}')
@patch('routes.config.DDNS_API_BASE', 'http://ddns.test', create=True)
def test_available_name_returns_true(self):
with patch('routes.config._ureq', create=True):
import io
resp_mock = MagicMock()
resp_mock.read.return_value = b'{"available": true}'
resp_mock.__enter__ = lambda s: resp_mock
resp_mock.__exit__ = MagicMock(return_value=False)
with patch('urllib.request.urlopen', return_value=resp_mock):
r = self._get('testname')
self.assertEqual(r.status_code, 200)
body = json.loads(r.data)
self.assertTrue(body['available'])
@patch('routes.config.DDNS_API_BASE', 'http://ddns.test', create=True)
def test_taken_name_returns_false(self):
resp_mock = MagicMock()
resp_mock.read.return_value = b'{"available": false}'
resp_mock.__enter__ = lambda s: resp_mock
resp_mock.__exit__ = MagicMock(return_value=False)
with patch('urllib.request.urlopen', return_value=resp_mock):
r = self._get('taken')
self.assertEqual(r.status_code, 200)
body = json.loads(r.data)
self.assertFalse(body['available'])
def test_unreachable_returns_503(self):
import urllib.error
with patch('urllib.request.urlopen', side_effect=OSError('conn refused')):
r = self._get('anything')
self.assertEqual(r.status_code, 503)
body = json.loads(r.data)
self.assertIsNone(body['available'])
class TestUpdateDdnsConfig(unittest.TestCase):
def setUp(self):
self.client = _make_client()
def _put(self, payload):
return self.client.put(
'/api/ddns',
data=json.dumps(payload),
content_type='application/json',
)
def test_invalid_domain_mode_returns_400(self):
r = self._put({'domain_mode': 'invalid_mode'})
self.assertEqual(r.status_code, 400)
self.assertIn('domain_mode', json.loads(r.data)['error'])
def test_cloudflare_requires_domain_name(self):
r = self._put({'domain_mode': 'cloudflare', 'cloudflare_api_token': 'tok'})
self.assertEqual(r.status_code, 400)
self.assertIn('domain_name', json.loads(r.data)['error'])
def test_cloudflare_invalid_token_returns_422(self):
import urllib.error
with patch('urllib.request.urlopen', side_effect=urllib.error.HTTPError(
None, 403, 'Forbidden', {}, None
)):
r = self._put({'domain_mode': 'cloudflare', 'domain_name': 'home.example.com',
'cloudflare_api_token': 'bad-token'})
self.assertEqual(r.status_code, 422)
def test_cloudflare_valid_token_saves_config(self):
from app import config_manager
resp_mock = MagicMock()
resp_mock.read.return_value = b'{"success": true}'
resp_mock.__enter__ = lambda s: resp_mock
resp_mock.__exit__ = MagicMock(return_value=False)
with patch('urllib.request.urlopen', return_value=resp_mock):
with patch.object(config_manager, 'set_ddns_config') as mock_set_ddns, \
patch.object(config_manager, 'set_identity_field') as mock_set_id:
r = self._put({'domain_mode': 'cloudflare', 'domain_name': 'home.example.com',
'cloudflare_api_token': 'valid-token'})
self.assertEqual(r.status_code, 200)
self.assertTrue(json.loads(r.data)['updated'])
mock_set_ddns.assert_called_once()
mock_set_id.assert_any_call('domain_mode', 'cloudflare')
def test_duckdns_requires_domain_name(self):
r = self._put({'domain_mode': 'duckdns', 'duckdns_token': 'tok'})
self.assertEqual(r.status_code, 400)
def test_duckdns_invalid_token_returns_422(self):
resp_mock = MagicMock()
resp_mock.read.return_value = b'KO'
resp_mock.__enter__ = lambda s: resp_mock
resp_mock.__exit__ = MagicMock(return_value=False)
with patch('urllib.request.urlopen', return_value=resp_mock):
r = self._put({'domain_mode': 'duckdns', 'domain_name': 'myname.duckdns.org',
'duckdns_token': 'bad'})
self.assertEqual(r.status_code, 422)
def test_lan_mode_saves_without_validation(self):
from app import config_manager
with patch.object(config_manager, 'set_ddns_config') as mock_ddns, \
patch.object(config_manager, 'set_identity_field') as mock_id:
r = self._put({'domain_mode': 'lan'})
self.assertEqual(r.status_code, 200)
mock_ddns.assert_called_once()
mock_id.assert_any_call('domain_mode', 'lan')
def test_http01_mode_saves_with_domain(self):
from app import config_manager
with patch.object(config_manager, 'set_ddns_config') as mock_ddns, \
patch.object(config_manager, 'set_identity_field') as mock_id:
r = self._put({'domain_mode': 'http01', 'domain_name': 'home.example.com'})
self.assertEqual(r.status_code, 200)
mock_id.assert_any_call('domain_name', 'home.example.com')
class TestDdnsRegister(unittest.TestCase):
def setUp(self):
self.client = _make_client()
def test_non_pic_ngo_provider_returns_400(self):
from app import config_manager
with patch.object(config_manager, 'configs', {'ddns': {'provider': 'cloudflare'}, '_identity': {}}):
r = self.client.post('/api/ddns/register')
self.assertEqual(r.status_code, 400)
def test_missing_cell_name_returns_400(self):
from app import config_manager
with patch.object(config_manager, 'configs', {'ddns': {'provider': 'pic_ngo'}, '_identity': {}}):
r = self.client.post('/api/ddns/register')
self.assertEqual(r.status_code, 400)
self.assertIn('cell_name', json.loads(r.data)['error'])
def test_register_success(self):
from app import config_manager
from ddns_manager import DDNSManager
with patch.object(config_manager, 'configs', {
'ddns': {'provider': 'pic_ngo'},
'_identity': {'cell_name': 'mypic'}
}):
with patch.object(DDNSManager, 'register', return_value={'subdomain': 'mypic.pic.ngo', 'token': 'tok'}) as mock_reg, \
patch.object(config_manager, 'set_identity_field') as mock_id:
r = self.client.post('/api/ddns/register')
self.assertEqual(r.status_code, 200)
body = json.loads(r.data)
self.assertTrue(body['registered'])
self.assertEqual(body['subdomain'], 'mypic.pic.ngo')
if __name__ == '__main__':
unittest.main()