#!/usr/bin/env python3 """ Tests for POST /api/config/apply. The route reads _pending_restart from config_manager, spawns a background thread/process, clears the pending flag, and returns 200. We mock subprocess.Popen / subprocess.run and docker.from_env so the tests run without Docker, and we capture what command-line arguments would be used. """ import sys import json import threading import unittest from pathlib import Path from unittest.mock import patch, MagicMock, call api_dir = Path(__file__).parent.parent / 'api' sys.path.insert(0, str(api_dir)) from app import app, _set_pending_restart, _clear_pending_restart, config_manager class TestConfigApplyRoute(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() _clear_pending_restart() def tearDown(self): _clear_pending_restart() # ── No pending changes ───────────────────────────────────────────────── def test_apply_with_no_pending_returns_200(self): r = self.client.post('/api/config/apply') self.assertEqual(r.status_code, 200) def test_apply_with_no_pending_returns_no_changes_message(self): r = self.client.post('/api/config/apply') data = json.loads(r.data) self.assertIn('message', data) self.assertIn('No pending', data['message']) # ── Pending changes present ──────────────────────────────────────────── @patch('subprocess.Popen') @patch('docker.from_env') def test_apply_with_pending_returns_200(self, mock_docker, mock_popen): mock_docker.side_effect = Exception('no docker in test') mock_popen.return_value = MagicMock() _set_pending_restart(['dns_port: 53 → 5353'], ['*']) r = self.client.post('/api/config/apply') self.assertEqual(r.status_code, 200) @patch('subprocess.Popen') @patch('docker.from_env') def test_apply_with_pending_returns_restart_in_progress(self, mock_docker, mock_popen): mock_docker.side_effect = Exception('no docker in test') mock_popen.return_value = MagicMock() _set_pending_restart(['something changed'], ['*']) r = self.client.post('/api/config/apply') data = json.loads(r.data) self.assertTrue(data.get('restart_in_progress')) # ── Pending state marked "applying" after apply (not immediately cleared) ─ @patch('threading.Thread') @patch('docker.from_env') def test_apply_sets_applying_flag(self, mock_docker, mock_thread): mock_docker.side_effect = Exception('no docker in test') # Don't actually start the thread so we don't need subprocess mock_thread.return_value = MagicMock() _set_pending_restart(['config changed'], ['*']) self.client.post('/api/config/apply') pending = config_manager.configs.get('_pending_restart', {}) # The route now marks needs_restart=True + applying=True instead of clearing # immediately. The helper container clears the flag on success; if the helper # fails, needs_restart stays set so the UI continues showing pending changes. self.assertTrue(pending.get('needs_restart', False)) self.assertTrue(pending.get('applying', False)) # ── needs_network_recreate=True → helper script includes 'down' ──────── @patch('subprocess.Popen') @patch('docker.from_env') def test_apply_network_recreate_spawns_popen_with_down_command( self, mock_docker, mock_popen): mock_docker.side_effect = Exception('no docker in test') mock_popen.return_value = MagicMock() # Set up a wildcard pending change that also requires network recreation _set_pending_restart(['ip_range changed'], ['*']) config_manager.configs['_pending_restart']['network_recreate'] = True r = self.client.post('/api/config/apply') self.assertEqual(r.status_code, 200) # Wait for background thread to call Popen import time for _ in range(20): if mock_popen.called: break time.sleep(0.1) self.assertTrue(mock_popen.called, 'Expected subprocess.Popen to be called for wildcard restart') args, kwargs = mock_popen.call_args cmd = args[0] # cmd is the full docker run ... sh -c 'script' script_arg = cmd[-1] # the -c argument self.assertIn('down', script_arg, f'Expected "down" in helper script when network_recreate=True, got: {script_arg}') # ── needs_network_recreate=False → helper script uses only 'up -d' ───── @patch('subprocess.Popen') @patch('docker.from_env') def test_apply_no_network_recreate_spawns_popen_without_down( self, mock_docker, mock_popen): mock_docker.side_effect = Exception('no docker in test') mock_popen.return_value = MagicMock() _set_pending_restart(['port changed'], ['*']) # network_recreate defaults to False self.client.post('/api/config/apply') import time for _ in range(20): if mock_popen.called: break time.sleep(0.1) self.assertTrue(mock_popen.called) args, _ = mock_popen.call_args script_arg = args[0][-1] self.assertNotIn(' down', script_arg, f'Did not expect "down" in helper script when network_recreate=False') self.assertIn('up -d', script_arg) # ── Specific containers (not wildcard) ───────────────────────────────── @patch('subprocess.run') @patch('docker.from_env') def test_apply_specific_containers_uses_subprocess_run( self, mock_docker, mock_run): mock_docker.side_effect = Exception('no docker in test') mock_run.return_value = MagicMock(returncode=0, stderr='') _set_pending_restart(['dns port changed'], ['dns']) r = self.client.post('/api/config/apply') self.assertEqual(r.status_code, 200) # Give the daemon thread a moment to call subprocess.run import time for _ in range(30): # Look for the compose call specifically (may not be the last call) compose_calls = [ c for c in mock_run.call_args_list if 'compose' in (c.args[0] if c.args else []) ] if compose_calls: break time.sleep(0.1) compose_calls = [ c for c in mock_run.call_args_list if c.args and 'compose' in c.args[0] ] self.assertTrue( len(compose_calls) > 0, f'Expected a subprocess.run call containing "compose"; got calls: {mock_run.call_args_list}' ) cmd = compose_calls[-1].args[0] self.assertIn('up', cmd) self.assertIn('-d', cmd) self.assertIn('dns', cmd) # ── Exception in route body returns 500 ─────────────────────────────── @patch('app.config_manager') def test_apply_returns_500_on_unexpected_exception(self, mock_cm): mock_cm.configs = MagicMock() mock_cm.configs.get.side_effect = Exception('unexpected failure') r = self.client.post('/api/config/apply') self.assertEqual(r.status_code, 500) self.assertIn('error', json.loads(r.data)) class TestDdnsConfigUpdatesFiresIdentityChanged(unittest.TestCase): """PUT /api/ddns must publish IDENTITY_CHANGED so CaddyManager regenerates.""" def setUp(self): app.config['TESTING'] = True self.client = app.test_client() def _put_ddns(self, payload=None): if payload is None: payload = {'domain_mode': 'pic_ngo', 'cell_name': 'test', 'domain': 'pic_ngo'} return self.client.put( '/api/ddns', data=json.dumps(payload), content_type='application/json', ) @patch('app.service_bus') @patch('app.config_manager') def test_fires_identity_changed_on_success(self, mock_cm, mock_bus): mock_cm.configs = { '_identity': { 'cell_name': 'test', 'domain': 'pic_ngo', 'domain_name': '', 'domain_mode': 'pic_ngo', } } mock_cm.set_identity_field = MagicMock() mock_cm.get_effective_domain = MagicMock(return_value='test.pic.ngo') mock_cm.validate_ddns_config = MagicMock(return_value=None) r = self._put_ddns() self.assertIn(r.status_code, (200, 204)) self.assertTrue(mock_bus.publish_event.called, 'Expected service_bus.publish_event to be called') args = mock_bus.publish_event.call_args # first positional arg should be an EventType with value IDENTITY_CHANGED event_arg = args[0][0] self.assertEqual(str(event_arg).upper().replace('.', '_'), 'EVENTTYPE_IDENTITY_CHANGED') @patch('app.service_bus') @patch('app.config_manager') def test_identity_changed_payload_contains_domain_fields(self, mock_cm, mock_bus): mock_cm.configs = { '_identity': { 'cell_name': 'mycell', 'domain': 'pic_ngo', 'domain_name': '', 'domain_mode': 'pic_ngo', } } mock_cm.set_identity_field = MagicMock() mock_cm.get_effective_domain = MagicMock(return_value='mycell.pic.ngo') mock_cm.validate_ddns_config = MagicMock(return_value=None) self._put_ddns({'domain_mode': 'pic_ngo', 'cell_name': 'mycell', 'domain': 'pic_ngo'}) if mock_bus.publish_event.called: kwargs = mock_bus.publish_event.call_args[1] if mock_bus.publish_event.call_args[1] else {} pos_args = mock_bus.publish_event.call_args[0] # payload is 3rd positional arg if len(pos_args) >= 3: payload = pos_args[2] self.assertIn('cell_name', payload) self.assertIn('effective_domain', payload) class TestCaddyCertStatusRoute(unittest.TestCase): """GET /api/caddy/cert-status delegates to CaddyManager and handles errors.""" def setUp(self): app.config['TESTING'] = True self.client = app.test_client() def test_returns_cert_status_200(self): expected = { 'status': 'valid', 'expiry': '2026-12-01T00:00:00+00:00', 'days_remaining': 179, } mock_caddy = MagicMock() mock_caddy.get_cert_status_fresh.return_value = expected with patch('app.caddy_manager', mock_caddy): r = self.client.get('/api/caddy/cert-status') self.assertEqual(r.status_code, 200) data = json.loads(r.data) self.assertEqual(data['status'], 'valid') self.assertEqual(data['days_remaining'], 179) def test_returns_500_on_exception(self): mock_caddy = MagicMock() mock_caddy.get_cert_status_fresh.side_effect = RuntimeError('ssl timeout') with patch('app.caddy_manager', mock_caddy): r = self.client.get('/api/caddy/cert-status') self.assertEqual(r.status_code, 500) data = json.loads(r.data) self.assertIn('error', data) def test_calls_get_cert_status_fresh_with_max_age(self): mock_caddy = MagicMock() mock_caddy.get_cert_status_fresh.return_value = {'status': 'internal'} with patch('app.caddy_manager', mock_caddy): self.client.get('/api/caddy/cert-status') mock_caddy.get_cert_status_fresh.assert_called_once() call_kwargs = mock_caddy.get_cert_status_fresh.call_args # max_age_seconds should be passed (positional or keyword) all_args = list(call_kwargs[0]) + list(call_kwargs[1].values()) self.assertTrue( any(isinstance(a, int) and a > 0 for a in all_args), 'Expected a positive max_age_seconds argument', ) if __name__ == '__main__': unittest.main()