Files
pic/tests/test_config_manager.py
T
roof 2455fe189e fix: apply_cell_name regex now matches zone files with TTL field
_generate_zone_content writes records as "name TTL IN A value" but the
regex only matched "name IN A value" (no TTL), so renaming the cell
never updated the DNS hostname record. Updated regex to make TTL optional.
Also fixed the unit test zone fixture to use the actual generated format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 09:32:51 -04:00

427 lines
19 KiB
Python

#!/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 TestNetworkManagerApplyCellName(unittest.TestCase):
"""apply_cell_name updates the DNS zone hostname record."""
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)
with open(os.path.join(self.config_dir, 'dhcp', 'dnsmasq.conf'), 'w') as f:
f.write('domain=cell\n')
with open(os.path.join(self.config_dir, 'ntp', 'chrony.conf'), 'w') as f:
f.write('server pool.ntp.org iburst\n')
# Create a zone file matching _generate_zone_content format (name TTL IN type value)
with open(os.path.join(self.data_dir, 'dns', 'cell.zone'), 'w') as f:
f.write('$TTL 3600\n'
'@ IN SOA cell. admin.cell. (\n'
' 2024010101 ; Serial\n'
' 3600 ; Refresh\n'
' )\n\n'
'ns1 3600 IN A 172.20.0.3\n'
'mycell 3600 IN A 172.20.0.2\n'
'@ 3600 IN A 172.20.0.2\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_cell_name_renames_host_record(self, mock_run):
mock_run.return_value = MagicMock(returncode=0)
result = self.nm.apply_cell_name('mycell', 'newcell')
zone = open(os.path.join(self.data_dir, 'dns', 'cell.zone')).read()
self.assertIn('newcell', zone)
self.assertIn('172.20.0.2', zone)
# old name must be gone from A records (the @ record still contains 172.20.0.2)
self.assertNotIn('mycell 3600 IN A', zone)
self.assertIn('cell-dns', ' '.join(result['restarted']))
@patch('subprocess.run')
def test_apply_cell_name_noop_when_same(self, mock_run):
mock_run.return_value = MagicMock(returncode=0)
result = self.nm.apply_cell_name('mycell', 'mycell')
self.assertEqual(result['restarted'], [])
mock_run.assert_not_called()
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()