#!/usr/bin/env python3 """ Tests for ConfigManager """ import unittest import json import tempfile import os import shutil from unittest.mock import Mock, patch, MagicMock import sys from pathlib import Path # Add api directory to path api_dir = Path(__file__).parent.parent / 'api' sys.path.insert(0, str(api_dir)) from config_manager import ConfigManager class TestConfigManager(unittest.TestCase): """Test the configuration manager functionality""" def setUp(self): self.temp_dir = tempfile.mkdtemp() self.config_file = os.path.join(self.temp_dir, 'cell_config.json') self.data_dir = os.path.join(self.temp_dir, 'data') os.makedirs(self.data_dir, exist_ok=True) self.config_manager = ConfigManager(self.config_file, self.data_dir) def tearDown(self): shutil.rmtree(self.temp_dir) def test_initialization(self): """Test config manager initialization""" self.assertTrue(os.path.exists(self.config_file)) self.assertTrue(os.path.exists(self.data_dir)) self.assertTrue(os.path.exists(self.config_manager.backup_dir)) self.assertIsNotNone(self.config_manager.service_schemas) def test_get_service_config(self): """Test getting service configuration""" # Test with non-existent service with self.assertRaises(ValueError): self.config_manager.get_service_config('nonexistent_service') # Test with valid service config = self.config_manager.get_service_config('network') self.assertEqual(config, {}) def test_update_service_config(self): """Test updating service configuration""" test_config = { 'dns_port': 53, 'dhcp_range': '10.0.0.100-10.0.0.200', 'ntp_servers': ['pool.ntp.org'] } success = self.config_manager.update_service_config('network', test_config) self.assertTrue(success) # Verify config was saved config = self.config_manager.get_service_config('network') self.assertEqual(config['dns_port'], 53) self.assertEqual(config['dhcp_range'], '10.0.0.100-10.0.0.200') self.assertEqual(config['ntp_servers'], ['pool.ntp.org']) def test_validate_config(self): """Test configuration validation""" # Test valid config valid_config = { 'dns_port': 53, 'dhcp_range': '10.0.0.100-10.0.0.200', 'ntp_servers': ['pool.ntp.org'] } validation = self.config_manager.validate_config('network', valid_config) self.assertTrue(validation['valid']) self.assertEqual(len(validation['errors']), 0) # Test invalid config (missing required field) invalid_config = { 'dns_port': 53, 'ntp_servers': ['pool.ntp.org'] # Missing dhcp_range } validation = self.config_manager.validate_config('network', invalid_config) self.assertFalse(validation['valid']) self.assertGreater(len(validation['errors']), 0) def test_backup_and_restore(self): """Test backup and restore functionality""" # Create some test configs test_config = { 'dns_port': 53, 'dhcp_range': '10.0.0.100-10.0.0.200', 'ntp_servers': ['pool.ntp.org'] } self.config_manager.update_service_config('network', test_config) # Create backup backup_id = self.config_manager.backup_config() self.assertIsNotNone(backup_id) # List backups backups = self.config_manager.list_backups() self.assertIsInstance(backups, list) self.assertGreater(len(backups), 0) # Modify config modified_config = { 'dns_port': 5353, 'dhcp_range': '10.0.0.100-10.0.0.200', 'ntp_servers': ['pool.ntp.org'] } self.config_manager.update_service_config('network', modified_config) # Restore backup success = self.config_manager.restore_config(backup_id) self.assertTrue(success) # Verify restoration config = self.config_manager.get_service_config('network') self.assertEqual(config['dns_port'], 53) # Should be restored value def test_export_import_config(self): """Test export and import functionality""" # Create test configs test_configs = { 'network': { 'dns_port': 53, 'dhcp_range': '10.0.0.100-10.0.0.200', 'ntp_servers': ['pool.ntp.org'] }, 'wireguard': { 'port': 51820, 'private_key': 'test_key', 'address': '10.0.0.1/24' } } for service, config in test_configs.items(): self.config_manager.update_service_config(service, config) # Export config exported = self.config_manager.export_config() self.assertIsInstance(exported, str) # Import config success = self.config_manager.import_config(exported) self.assertTrue(success) # Verify import for service, expected_config in test_configs.items(): config = self.config_manager.get_service_config(service) for key, value in expected_config.items(): self.assertEqual(config[key], value) def test_get_all_configs(self): """Test getting all configurations""" # Create some test configs test_configs = { 'network': {'dns_port': 53, 'dhcp_range': '10.0.0.100-10.0.0.200', 'ntp_servers': ['pool.ntp.org']}, 'wireguard': {'port': 51820} } for service, config in test_configs.items(): self.config_manager.update_service_config(service, config) all_configs = self.config_manager.get_all_configs() self.assertIn('network', all_configs) self.assertIn('wireguard', all_configs) self.assertEqual(all_configs['network']['dns_port'], 53) def test_get_config_summary(self): """Test getting configuration summary""" # Create some test configs test_configs = { 'network': {'dns_port': 53, 'dhcp_range': '10.0.0.100-10.0.0.200', 'ntp_servers': ['pool.ntp.org']}, 'wireguard': {'port': 51820} } for service, config in test_configs.items(): self.config_manager.update_service_config(service, config) summary = self.config_manager.get_config_summary() self.assertIn('total_services', summary) self.assertIn('configured_services', summary) self.assertIn('backup_count', summary) def test_get_config_hash(self): """Test getting configuration hash""" test_config = {'dns_port': 53, 'dhcp_range': '10.0.0.100-10.0.0.200', 'ntp_servers': ['pool.ntp.org']} self.config_manager.update_service_config('network', test_config) hash1 = self.config_manager.get_config_hash('network') self.assertIsInstance(hash1, str) self.assertGreater(len(hash1), 0) # Update config and get new hash test_config['dns_port'] = 5353 self.config_manager.update_service_config('network', test_config) hash2 = self.config_manager.get_config_hash('network') self.assertNotEqual(hash1, hash2) def test_has_config_changed(self): """Test checking if configuration has changed""" test_config = {'dns_port': 53, 'dhcp_range': '10.0.0.100-10.0.0.200', 'ntp_servers': ['pool.ntp.org']} self.config_manager.update_service_config('network', test_config) original_hash = self.config_manager.get_config_hash('network') # Check if changed (should be False since we just set it) changed = self.config_manager.has_config_changed('network', original_hash) self.assertFalse(changed) # Update config test_config['dns_port'] = 5353 self.config_manager.update_service_config('network', test_config) # Check if changed (should be True) changed = self.config_manager.has_config_changed('network', original_hash) self.assertTrue(changed) def test_restore_does_not_zero_unconfigured_services(self): """Restore must not inject zero-filled entries for services absent from backup.""" # Only configure network before backup self.config_manager.update_service_config('network', { 'dns_port': 53, 'dhcp_range': '10.0.0.100,10.0.0.200,12h', 'ntp_servers': ['pool.ntp.org'] }) backup_id = self.config_manager.backup_config() # Restore into a fresh manager (simulates restoring to a clean install) fresh_cfg_file = os.path.join(self.temp_dir, 'cell_config2.json') fresh = ConfigManager(fresh_cfg_file, self.data_dir) # Restore needs the backup_dir to match fresh.backup_dir = self.config_manager.backup_dir success = fresh.restore_config(backup_id) self.assertTrue(success) # email was not in the backup — it must NOT appear with port=0 email_cfg = fresh.get_service_config('email') self.assertNotIn('smtp_port', email_cfg, "restore must not inject zero-filled entries for services not in backup") self.assertNotIn('imap_port', email_cfg) # network was in the backup — it must be intact net_cfg = fresh.get_service_config('network') self.assertEqual(net_cfg['dns_port'], 53) def test_restore_does_not_zero_import(self): """import_config must not inject zero-filled entries for absent services.""" export_data = json.dumps({ 'network': {'dns_port': 53, 'dhcp_range': '10.0.0.100,10.0.0.200,12h', 'ntp_servers': []} }) success = self.config_manager.import_config(export_data) self.assertTrue(success) email_cfg = self.config_manager.get_service_config('email') self.assertNotIn('smtp_port', email_cfg, "import must not inject zero-filled entries for absent services") class TestNetworkManagerApply(unittest.TestCase): """Test apply_config / apply_domain actually write real config files.""" def setUp(self): self.test_dir = tempfile.mkdtemp() self.data_dir = os.path.join(self.test_dir, 'data') self.config_dir = os.path.join(self.test_dir, 'config') os.makedirs(os.path.join(self.data_dir, 'dns'), exist_ok=True) os.makedirs(os.path.join(self.config_dir, 'dhcp'), exist_ok=True) os.makedirs(os.path.join(self.config_dir, 'ntp'), exist_ok=True) # Seed minimal config files with open(os.path.join(self.config_dir, 'dhcp', 'dnsmasq.conf'), 'w') as f: f.write('dhcp-range=10.0.0.100,10.0.0.200,12h\ndomain=cell\n') with open(os.path.join(self.config_dir, 'ntp', 'chrony.conf'), 'w') as f: f.write('server time.google.com iburst\nserver pool.ntp.org iburst\n') sys.path.insert(0, str(Path(__file__).parent.parent / 'api')) from network_manager import NetworkManager self.nm = NetworkManager(self.data_dir, self.config_dir) def tearDown(self): shutil.rmtree(self.test_dir) @patch('subprocess.run') def test_apply_config_writes_dhcp_range(self, mock_run): mock_run.return_value = MagicMock(returncode=0) result = self.nm.apply_config({'dhcp_range': '192.168.1.100,192.168.1.200,24h'}) dhcp_conf = open(os.path.join(self.config_dir, 'dhcp', 'dnsmasq.conf')).read() self.assertIn('192.168.1.100,192.168.1.200,24h', dhcp_conf) self.assertIn('cell-dhcp', ' '.join(result['restarted'])) @patch('subprocess.run') def test_apply_config_writes_ntp_servers(self, mock_run): mock_run.return_value = MagicMock(returncode=0) result = self.nm.apply_config({'ntp_servers': ['ntp1.example.com', 'ntp2.example.com']}) ntp_conf = open(os.path.join(self.config_dir, 'ntp', 'chrony.conf')).read() self.assertIn('server ntp1.example.com iburst', ntp_conf) self.assertIn('server ntp2.example.com iburst', ntp_conf) # Old servers must be gone self.assertNotIn('time.google.com', ntp_conf) self.assertIn('cell-ntp', result['restarted']) @patch('subprocess.run') def test_apply_domain_updates_dnsmasq(self, mock_run): mock_run.return_value = MagicMock(returncode=0) result = self.nm.apply_domain('newdomain.local') dhcp_conf = open(os.path.join(self.config_dir, 'dhcp', 'dnsmasq.conf')).read() self.assertIn('domain=newdomain.local', dhcp_conf) self.assertNotIn('domain=cell', dhcp_conf) @patch('subprocess.run') def test_apply_domain_updates_corefile(self, mock_run): """apply_domain must rewrite the Corefile zone name and reload CoreDNS.""" mock_run.return_value = MagicMock(returncode=0) # Create a Corefile with zone 'cell' dns_conf_dir = os.path.join(self.config_dir, 'dns') os.makedirs(dns_conf_dir, exist_ok=True) corefile = os.path.join(dns_conf_dir, 'Corefile') with open(corefile, 'w') as f: f.write('. {\n forward . 8.8.8.8\n}\ncell {\n file /data/cell.zone\n log\n}\n') # Create zone file zone_file = os.path.join(self.data_dir, 'dns', 'cell.zone') with open(zone_file, 'w') as f: f.write('$ORIGIN cell.\n$TTL 300\n@ IN SOA ns1.cell. admin.cell. 2024010101 3600 900 604800 300\n') self.nm.apply_domain('newdomain.local') corefile_content = open(corefile).read() self.assertIn('newdomain.local', corefile_content, "Corefile must reference the new domain zone") self.assertNotIn('\ncell {', corefile_content, "Corefile must not keep old 'cell' zone block") class TestEmailManagerApply(unittest.TestCase): """Test email_manager.apply_config writes mailserver.env correctly.""" def setUp(self): self.test_dir = tempfile.mkdtemp() self.data_dir = os.path.join(self.test_dir, 'data') self.config_dir = os.path.join(self.test_dir, 'config') os.makedirs(os.path.join(self.config_dir, 'mail'), exist_ok=True) os.makedirs(os.path.join(self.data_dir, 'email'), exist_ok=True) with open(os.path.join(self.config_dir, 'mail', 'mailserver.env'), 'w') as f: f.write('OVERRIDE_HOSTNAME=mail.cell\nPOSTMASTER_ADDRESS=admin@cell\nLOG_LEVEL=warn\n') sys.path.insert(0, str(Path(__file__).parent.parent / 'api')) from email_manager import EmailManager self.em = EmailManager(self.data_dir, self.config_dir) def tearDown(self): shutil.rmtree(self.test_dir) @patch('subprocess.run') def test_apply_config_updates_mailserver_env(self, mock_run): mock_run.return_value = MagicMock(returncode=0) result = self.em.apply_config({'domain': 'example.local'}) env = open(os.path.join(self.config_dir, 'mail', 'mailserver.env')).read() self.assertIn('OVERRIDE_HOSTNAME=mail.example.local', env) self.assertIn('POSTMASTER_ADDRESS=admin@example.local', env) self.assertIn('LOG_LEVEL=warn', env, "other env vars must be preserved") self.assertIn('cell-mail', result['restarted']) @patch('subprocess.run') def test_apply_config_no_domain_no_restart(self, mock_run): mock_run.return_value = MagicMock(returncode=0) result = self.em.apply_config({'smtp_port': 587}) # smtp_port alone doesn't restart cell-mail (no mailserver.env key to change) self.assertEqual(result['restarted'], []) if __name__ == '__main__': unittest.main()