Files
pic/tests/test_ddns_endpoints.py
T
roof 61e8631c7d feat: DDNS settings integration — check availability, update credentials
- 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>
2026-05-26 14:35:37 -04:00

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()