7c94d934e1
TestEnvFileWrittenOnPortChange (7 tests) confirms that PUT /api/config with a port change actually writes the new variable to the .env file consumed by docker compose — the critical link between 'config saved' and 'docker binding changes on next restart'. Tests cover calendar, webdav, filegator, wireguard, email; also verifies changing one port does not reset unrelated ports, and WG_PORT appears exactly once with the new value. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
361 lines
14 KiB
Python
361 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests for the pending-restart system: helpers, endpoints, and cancel."""
|
|
|
|
import sys
|
|
import os
|
|
import json
|
|
import unittest
|
|
import tempfile
|
|
import shutil
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
api_dir = Path(__file__).parent.parent / 'api'
|
|
sys.path.insert(0, str(api_dir))
|
|
|
|
from app import app, _set_pending_restart, _clear_pending_restart, _collect_service_ports, config_manager
|
|
|
|
|
|
class TestSetPendingRestart(unittest.TestCase):
|
|
def setUp(self):
|
|
_clear_pending_restart()
|
|
|
|
def tearDown(self):
|
|
_clear_pending_restart()
|
|
|
|
def test_sets_needs_restart(self):
|
|
_set_pending_restart(['something changed'])
|
|
p = config_manager.configs.get('_pending_restart', {})
|
|
self.assertTrue(p['needs_restart'])
|
|
|
|
def test_stores_changes(self):
|
|
_set_pending_restart(['port x changed'])
|
|
p = config_manager.configs['_pending_restart']
|
|
self.assertIn('port x changed', p['changes'])
|
|
|
|
def test_default_containers_is_wildcard(self):
|
|
_set_pending_restart(['some change'])
|
|
p = config_manager.configs['_pending_restart']
|
|
self.assertEqual(p['containers'], ['*'])
|
|
|
|
def test_specific_containers_stored(self):
|
|
_set_pending_restart(['dns port changed'], ['dns'])
|
|
p = config_manager.configs['_pending_restart']
|
|
self.assertEqual(p['containers'], ['dns'])
|
|
|
|
def test_accumulates_changes_on_second_call(self):
|
|
_set_pending_restart(['change A'], ['dns'])
|
|
_set_pending_restart(['change B'], ['mail'])
|
|
p = config_manager.configs['_pending_restart']
|
|
self.assertIn('change A', p['changes'])
|
|
self.assertIn('change B', p['changes'])
|
|
|
|
def test_accumulates_containers_on_second_call(self):
|
|
_set_pending_restart(['A'], ['dns'])
|
|
_set_pending_restart(['B'], ['mail'])
|
|
p = config_manager.configs['_pending_restart']
|
|
self.assertIn('dns', p['containers'])
|
|
self.assertIn('mail', p['containers'])
|
|
|
|
def test_wildcard_absorbs_specific(self):
|
|
_set_pending_restart(['ip range changed'], ['*'])
|
|
_set_pending_restart(['port changed'], ['dns'])
|
|
p = config_manager.configs['_pending_restart']
|
|
self.assertEqual(p['containers'], ['*'])
|
|
|
|
def test_specific_escalates_to_wildcard(self):
|
|
_set_pending_restart(['A'], ['dns'])
|
|
_set_pending_restart(['B'], ['*'])
|
|
p = config_manager.configs['_pending_restart']
|
|
self.assertEqual(p['containers'], ['*'])
|
|
|
|
def test_no_duplicate_containers(self):
|
|
_set_pending_restart(['A'], ['mail'])
|
|
_set_pending_restart(['B'], ['mail'])
|
|
p = config_manager.configs['_pending_restart']
|
|
self.assertEqual(p['containers'].count('mail'), 1)
|
|
|
|
|
|
class TestClearPendingRestart(unittest.TestCase):
|
|
def test_clears_flag(self):
|
|
_set_pending_restart(['something'])
|
|
_clear_pending_restart()
|
|
p = config_manager.configs.get('_pending_restart', {})
|
|
self.assertFalse(p.get('needs_restart', False))
|
|
|
|
def test_clears_changes(self):
|
|
_set_pending_restart(['something'])
|
|
_clear_pending_restart()
|
|
p = config_manager.configs.get('_pending_restart', {})
|
|
self.assertEqual(p.get('changes', []), [])
|
|
|
|
def test_clears_containers(self):
|
|
_set_pending_restart(['something'], ['dns'])
|
|
_clear_pending_restart()
|
|
p = config_manager.configs.get('_pending_restart', {})
|
|
self.assertEqual(p.get('containers', []), [])
|
|
|
|
|
|
class TestCollectServicePorts(unittest.TestCase):
|
|
def test_extracts_dns_port(self):
|
|
cfg = {'network': {'dns_port': 5353}}
|
|
ports = _collect_service_ports(cfg)
|
|
self.assertEqual(ports['dns_port'], 5353)
|
|
|
|
def test_extracts_wg_port_from_wireguard(self):
|
|
cfg = {'wireguard': {'port': 12345}}
|
|
ports = _collect_service_ports(cfg)
|
|
self.assertEqual(ports['wg_port'], 12345)
|
|
|
|
def test_extracts_wg_port_from_identity_fallback(self):
|
|
cfg = {'_identity': {'wireguard_port': 9999}}
|
|
ports = _collect_service_ports(cfg)
|
|
self.assertEqual(ports['wg_port'], 9999)
|
|
|
|
def test_wireguard_port_takes_priority_over_identity(self):
|
|
cfg = {'wireguard': {'port': 12345}, '_identity': {'wireguard_port': 9999}}
|
|
ports = _collect_service_ports(cfg)
|
|
self.assertEqual(ports['wg_port'], 12345)
|
|
|
|
def test_extracts_email_ports(self):
|
|
cfg = {'email': {'smtp_port': 2525, 'submission_port': 465, 'imap_port': 1993, 'webmail_port': 9000}}
|
|
ports = _collect_service_ports(cfg)
|
|
self.assertEqual(ports['mail_smtp_port'], 2525)
|
|
self.assertEqual(ports['mail_submission_port'], 465)
|
|
self.assertEqual(ports['mail_imap_port'], 1993)
|
|
self.assertEqual(ports['rainloop_port'], 9000)
|
|
|
|
def test_extracts_calendar_port(self):
|
|
cfg = {'calendar': {'port': 5233}}
|
|
ports = _collect_service_ports(cfg)
|
|
self.assertEqual(ports['radicale_port'], 5233)
|
|
|
|
def test_extracts_files_ports(self):
|
|
cfg = {'files': {'port': 8181, 'manager_port': 9090}}
|
|
ports = _collect_service_ports(cfg)
|
|
self.assertEqual(ports['webdav_port'], 8181)
|
|
self.assertEqual(ports['filegator_port'], 9090)
|
|
|
|
def test_missing_keys_not_in_result(self):
|
|
ports = _collect_service_ports({})
|
|
self.assertNotIn('dns_port', ports)
|
|
self.assertNotIn('wg_port', ports)
|
|
|
|
def test_empty_sections_not_in_result(self):
|
|
ports = _collect_service_ports({'email': {}})
|
|
self.assertNotIn('mail_smtp_port', ports)
|
|
|
|
|
|
class TestGetPendingEndpoint(unittest.TestCase):
|
|
def setUp(self):
|
|
app.config['TESTING'] = True
|
|
self.client = app.test_client()
|
|
_clear_pending_restart()
|
|
|
|
def tearDown(self):
|
|
_clear_pending_restart()
|
|
|
|
def test_returns_not_pending_by_default(self):
|
|
r = self.client.get('/api/config/pending')
|
|
self.assertEqual(r.status_code, 200)
|
|
data = json.loads(r.data)
|
|
self.assertFalse(data['needs_restart'])
|
|
|
|
def test_returns_pending_state(self):
|
|
_set_pending_restart(['dns port: 53 → 5353'], ['dns'])
|
|
r = self.client.get('/api/config/pending')
|
|
data = json.loads(r.data)
|
|
self.assertTrue(data['needs_restart'])
|
|
self.assertIn('dns port: 53 → 5353', data['changes'])
|
|
self.assertIn('dns', data['containers'])
|
|
|
|
def test_returns_containers_field(self):
|
|
_set_pending_restart(['x'], ['wireguard'])
|
|
data = json.loads(self.client.get('/api/config/pending').data)
|
|
self.assertIn('containers', data)
|
|
self.assertEqual(data['containers'], ['wireguard'])
|
|
|
|
|
|
class TestCancelPendingEndpoint(unittest.TestCase):
|
|
def setUp(self):
|
|
app.config['TESTING'] = True
|
|
self.client = app.test_client()
|
|
_clear_pending_restart()
|
|
|
|
def tearDown(self):
|
|
_clear_pending_restart()
|
|
|
|
def test_cancel_clears_pending(self):
|
|
_set_pending_restart(['something'], ['dns'])
|
|
r = self.client.delete('/api/config/pending')
|
|
self.assertEqual(r.status_code, 200)
|
|
p = config_manager.configs.get('_pending_restart', {})
|
|
self.assertFalse(p.get('needs_restart', False))
|
|
|
|
def test_cancel_returns_message(self):
|
|
_set_pending_restart(['something'])
|
|
data = json.loads(self.client.delete('/api/config/pending').data)
|
|
self.assertIn('message', data)
|
|
|
|
def test_cancel_idempotent_when_nothing_pending(self):
|
|
r = self.client.delete('/api/config/pending')
|
|
self.assertEqual(r.status_code, 200)
|
|
|
|
def test_get_after_cancel_shows_not_pending(self):
|
|
_set_pending_restart(['x'])
|
|
self.client.delete('/api/config/pending')
|
|
data = json.loads(self.client.get('/api/config/pending').data)
|
|
self.assertFalse(data['needs_restart'])
|
|
self.assertEqual(data['changes'], [])
|
|
|
|
|
|
class TestPortChangeDetection(unittest.TestCase):
|
|
"""Test that port changes always trigger pending restart, even on first save."""
|
|
|
|
def setUp(self):
|
|
app.config['TESTING'] = True
|
|
self.client = app.test_client()
|
|
_clear_pending_restart()
|
|
# Remove any stored service configs so we start clean
|
|
for key in ('calendar', 'files', 'wireguard', 'network', 'email'):
|
|
config_manager.configs.pop(key, None)
|
|
self.tmp = tempfile.mkdtemp()
|
|
self.env_path = os.path.join(self.tmp, '.env')
|
|
|
|
def tearDown(self):
|
|
_clear_pending_restart()
|
|
for key in ('calendar', 'files', 'wireguard', 'network', 'email'):
|
|
config_manager.configs.pop(key, None)
|
|
shutil.rmtree(self.tmp)
|
|
|
|
def _put_config(self, payload):
|
|
return self.client.put('/api/config',
|
|
data=json.dumps(payload),
|
|
content_type='application/json')
|
|
|
|
def test_calendar_port_first_save_marks_pending(self):
|
|
"""First-time calendar port save should still queue pending restart."""
|
|
r = self._put_config({'calendar': {'port': 5233}})
|
|
self.assertEqual(r.status_code, 200)
|
|
p = config_manager.configs.get('_pending_restart', {})
|
|
self.assertTrue(p.get('needs_restart'), 'pending restart not set on first calendar port save')
|
|
self.assertIn('radicale', p.get('containers', []))
|
|
|
|
def test_files_port_first_save_marks_pending(self):
|
|
"""First-time files (webdav) port save should queue pending restart."""
|
|
r = self._put_config({'files': {'port': 8181}})
|
|
self.assertEqual(r.status_code, 200)
|
|
p = config_manager.configs.get('_pending_restart', {})
|
|
self.assertTrue(p.get('needs_restart'))
|
|
self.assertIn('webdav', p.get('containers', []))
|
|
|
|
def test_files_manager_port_first_save_marks_pending(self):
|
|
r = self._put_config({'files': {'manager_port': 9090}})
|
|
self.assertEqual(r.status_code, 200)
|
|
p = config_manager.configs.get('_pending_restart', {})
|
|
self.assertTrue(p.get('needs_restart'))
|
|
self.assertIn('filegator', p.get('containers', []))
|
|
|
|
def test_multiple_service_port_changes_accumulate_containers(self):
|
|
"""Saving two services should accumulate both containers in pending."""
|
|
self._put_config({'calendar': {'port': 5233}})
|
|
self._put_config({'files': {'port': 8181}})
|
|
p = config_manager.configs.get('_pending_restart', {})
|
|
self.assertTrue(p.get('needs_restart'))
|
|
containers = p.get('containers', [])
|
|
self.assertIn('radicale', containers)
|
|
self.assertIn('webdav', containers)
|
|
|
|
def test_same_port_as_default_no_pending(self):
|
|
"""Saving the default port value should NOT trigger pending restart."""
|
|
r = self._put_config({'calendar': {'port': 5232}}) # 5232 is default
|
|
self.assertEqual(r.status_code, 200)
|
|
p = config_manager.configs.get('_pending_restart', {})
|
|
self.assertFalse(p.get('needs_restart', False))
|
|
|
|
|
|
class TestEnvFileWrittenOnPortChange(unittest.TestCase):
|
|
"""Verify that PUT /api/config with a port change actually writes the new
|
|
port variable to the .env file consumed by docker compose.
|
|
|
|
This is the critical link between 'port saved in config' and 'docker binding
|
|
changes on next restart'. Without this the container would still bind the old
|
|
port even after apply is clicked.
|
|
"""
|
|
|
|
def setUp(self):
|
|
app.config['TESTING'] = True
|
|
self.client = app.test_client()
|
|
_clear_pending_restart()
|
|
for key in ('calendar', 'files', 'wireguard', 'network', 'email'):
|
|
config_manager.configs.pop(key, None)
|
|
self.tmp = tempfile.mkdtemp()
|
|
self.env_path = os.path.join(self.tmp, '.env')
|
|
# Pre-create .env so write_env_file can overwrite it
|
|
import ip_utils
|
|
ip_utils.write_env_file('172.20.0.0/16', self.env_path)
|
|
|
|
def tearDown(self):
|
|
_clear_pending_restart()
|
|
for key in ('calendar', 'files', 'wireguard', 'network', 'email'):
|
|
config_manager.configs.pop(key, None)
|
|
shutil.rmtree(self.tmp)
|
|
|
|
def _put_config(self, payload):
|
|
with patch.dict(os.environ, {'COMPOSE_ENV_FILE': self.env_path}):
|
|
return self.client.put('/api/config',
|
|
data=json.dumps(payload),
|
|
content_type='application/json')
|
|
|
|
def _env_content(self):
|
|
return open(self.env_path).read()
|
|
|
|
def test_calendar_port_written_to_env(self):
|
|
self._put_config({'calendar': {'port': 5299}})
|
|
self.assertIn('RADICALE_PORT=5299', self._env_content())
|
|
|
|
def test_webdav_port_written_to_env(self):
|
|
self._put_config({'files': {'port': 8181}})
|
|
self.assertIn('WEBDAV_PORT=8181', self._env_content())
|
|
|
|
def test_filegator_port_written_to_env(self):
|
|
self._put_config({'files': {'manager_port': 9090}})
|
|
self.assertIn('FILEGATOR_PORT=9090', self._env_content())
|
|
|
|
def test_wireguard_port_written_to_env(self):
|
|
self._put_config({'wireguard': {'port': 51999}})
|
|
self.assertIn('WG_PORT=51999', self._env_content())
|
|
|
|
def test_email_smtp_port_written_to_env(self):
|
|
self._put_config({'email': {'smtp_port': 2525}})
|
|
self.assertIn('MAIL_SMTP_PORT=2525', self._env_content())
|
|
|
|
def test_other_ports_unchanged_when_one_port_changes(self):
|
|
"""Changing calendar port must not reset unrelated ports to defaults."""
|
|
# First set webdav to a non-default
|
|
self._put_config({'files': {'port': 8181}})
|
|
# Then change calendar port
|
|
self._put_config({'calendar': {'port': 5299}})
|
|
content = self._env_content()
|
|
self.assertIn('RADICALE_PORT=5299', content)
|
|
self.assertIn('WEBDAV_PORT=8181', content) # must stay at 8181, not revert to 8080
|
|
|
|
def test_env_uses_symmetric_wg_port_for_docker_binding(self):
|
|
"""WG_PORT must be the same value on both sides of the docker port mapping.
|
|
|
|
docker-compose.yml uses ${WG_PORT:-51820}:${WG_PORT:-51820}/udp so the
|
|
host port and container port are always the same. This test verifies
|
|
a port change writes a single consistent value to .env so the daemon's
|
|
ListenPort matches the Docker binding.
|
|
"""
|
|
self._put_config({'wireguard': {'port': 51999}})
|
|
content = self._env_content()
|
|
self.assertIn('WG_PORT=51999', content)
|
|
# There must be only one WG_PORT line (no duplicate with old value)
|
|
wg_lines = [l for l in content.splitlines() if l.startswith('WG_PORT=')]
|
|
self.assertEqual(len(wg_lines), 1, f'Expected exactly one WG_PORT line, got: {wg_lines}')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|