Files
pic/tests/test_config_manager.py
T
roof bd71466a87
Unit Tests / test (push) Successful in 7m31s
fix: split-horizon DNS zone uses WireGuard IP, not Docker bridge IP
VPN peers can reach Caddy via the host's WireGuard interface (10.0.0.1),
not via the Docker bridge IP (172.20.0.2) which is unreachable outside
the container network. _bootstrap_dns now calls _get_wg_server_ip()
instead of ip_utils.get_service_ips() so the internal zone returns a
routable address for service subdomains.

Also log config save failures instead of silently swallowing them —
the silent PermissionError/OSError was masking write failures and
making it impossible to diagnose why installed services disappeared
after container restarts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 02:11:01 -04:00

546 lines
24 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 TestSaveAllConfigs(unittest.TestCase):
"""_save_all_configs must log errors instead of silently swallowing them."""
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.cm = ConfigManager(self.config_file, self.data_dir)
def tearDown(self):
shutil.rmtree(self.temp_dir)
def test_save_failure_is_logged_not_silenced(self):
"""When the config file cannot be written, _save_all_configs must log an error."""
with patch('builtins.open', side_effect=OSError('disk full')):
with self.assertLogs('config_manager', level='ERROR') as log:
self.cm._save_all_configs()
self.assertTrue(
any('write failed' in msg or 'NOT persisted' in msg for msg in log.output),
f'Expected error about write failure in logs, got: {log.output}',
)
def test_save_success_does_not_log_error(self):
"""A successful save must not produce error logs."""
import logging
with self.assertLogs('config_manager', level='DEBUG') as cm:
logging.getLogger('config_manager').debug('sentinel')
self.cm._save_all_configs()
errors = [m for m in cm.output if 'ERROR' in m and 'write failed' in m]
self.assertEqual(errors, [], 'Unexpected write-failure error on a successful save')
class TestGetEffectiveDomain(unittest.TestCase):
"""Tests for ConfigManager.get_effective_domain and get_internal_domain."""
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)
def tearDown(self):
shutil.rmtree(self.temp_dir)
def _make_cm(self, identity):
cm = ConfigManager(self.config_file, self.data_dir)
cm.configs['_identity'] = identity
return cm
def test_get_effective_domain_lan_mode(self):
cm = self._make_cm({'domain': 'home.local', 'domain_mode': 'lan'})
self.assertEqual(cm.get_effective_domain(), 'home.local')
def test_get_effective_domain_pic_ngo_uses_domain_name(self):
cm = self._make_cm({
'domain': 'home.local',
'domain_mode': 'pic_ngo',
'domain_name': 'home.pic.ngo',
})
self.assertEqual(cm.get_effective_domain(), 'home.pic.ngo')
def test_get_effective_domain_pic_ngo_fallback(self):
cm = self._make_cm({'domain': 'home.local', 'domain_mode': 'pic_ngo'})
self.assertEqual(cm.get_effective_domain(), 'home.local')
def test_get_internal_domain_always_returns_domain(self):
cm = self._make_cm({
'domain': 'home.local',
'domain_mode': 'pic_ngo',
'domain_name': 'home.pic.ngo',
})
self.assertEqual(cm.get_internal_domain(), 'home.local')
def test_get_internal_domain_ignores_domain_name(self):
cm = self._make_cm({
'domain': 'myzone.local',
'domain_mode': 'cloudflare',
'domain_name': 'example.com',
})
self.assertEqual(cm.get_internal_domain(), 'myzone.local')
def test_get_effective_domain_cloudflare_uses_domain_name(self):
cm = self._make_cm({
'domain': 'home.local',
'domain_mode': 'cloudflare',
'domain_name': 'example.com',
})
self.assertEqual(cm.get_effective_domain(), 'example.com')
def test_silent_migration_sets_unique_internal_domain(self):
"""When DDNS is active and domain is the generic 'cell', migration sets cell_name.local."""
config_file2 = os.path.join(self.temp_dir, 'cell_config2.json')
with open(config_file2, 'w') as f:
json.dump({
'_identity': {
'cell_name': 'alpha',
'domain': 'cell',
'domain_mode': 'pic_ngo',
}
}, f)
cm = ConfigManager(config_file2, self.data_dir)
self.assertEqual(cm.get_internal_domain(), 'alpha.local')
def test_silent_migration_does_not_touch_lan_mode(self):
"""Migration must leave domain unchanged when domain_mode is 'lan'."""
config_file2 = os.path.join(self.temp_dir, 'cell_config3.json')
with open(config_file2, 'w') as f:
json.dump({
'_identity': {
'cell_name': 'beta',
'domain': 'cell',
'domain_mode': 'lan',
}
}, f)
cm = ConfigManager(config_file2, self.data_dir)
self.assertEqual(cm.get_internal_domain(), 'cell')
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()