61e8631c7d
- GET /api/config now returns domain_mode, domain_name, ddns.{provider,subdomain,has_token}
- GET /api/ddns/check/<name> proxies availability check to DDNS service
- PUT /api/ddns validates and saves cloudflare/duckdns credentials post-setup
- When cell_name changes for pic_ngo provider, auto-registers the new subdomain
- Settings: Cell Name shows availability badge for pic_ngo; auto-save blocks on taken
- Settings: new External Domain & DDNS section — pic_ngo info, cloudflare/duckdns edit
- 11 new tests for the two new endpoints (all pass)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
141 lines
5.6 KiB
Python
141 lines
5.6 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')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|