#!/usr/bin/env python3 """ Unit tests for NetworkManager class """ 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)) import unittest import tempfile import os import json import shutil from unittest.mock import patch, MagicMock from datetime import datetime # Add parent directory to path for imports import sys sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from network_manager import NetworkManager class TestNetworkManager(unittest.TestCase): """Test cases for NetworkManager class""" def setUp(self): """Set up test environment""" 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(self.data_dir, exist_ok=True) os.makedirs(self.config_dir, exist_ok=True) # Create NetworkManager instance self.network_manager = NetworkManager(self.data_dir, self.config_dir) def tearDown(self): """Clean up test environment""" shutil.rmtree(self.test_dir) def test_initialization(self): """Test NetworkManager initialization""" self.assertEqual(self.network_manager.data_dir, self.data_dir) self.assertEqual(self.network_manager.config_dir, self.config_dir) self.assertTrue(os.path.exists(self.network_manager.dns_zones_dir)) self.assertTrue(os.path.exists(os.path.dirname(self.network_manager.dhcp_leases_file))) def test_generate_zone_content(self): """Test DNS zone content generation""" records = [ {'name': 'test1', 'type': 'A', 'value': '192.168.1.10', 'ttl': 3600}, {'name': 'test2', 'type': 'CNAME', 'value': 'test1', 'ttl': 1800} ] content = self.network_manager._generate_zone_content('test.cell', records) self.assertIn('test.cell', content) self.assertIn('SOA', content) self.assertIn('192.168.1.10', content) self.assertIn('test1', content) self.assertIn('CNAME', content) def test_add_dns_record(self): """Test adding DNS record""" success = self.network_manager.add_dns_record('test.cell', 'test', 'A', '192.168.1.100') self.assertTrue(success) # Check if zone file was created zone_file = os.path.join(self.network_manager.dns_zones_dir, 'test.cell.zone') self.assertTrue(os.path.exists(zone_file)) # Check content with open(zone_file, 'r') as f: content = f.read() self.assertIn('test', content) self.assertIn('192.168.1.100', content) def test_remove_dns_record(self): """Test removing DNS record""" # Add a record first self.network_manager.add_dns_record('test.cell', 'test', 'A', '192.168.1.100') # Remove it success = self.network_manager.remove_dns_record('test.cell', 'test', 'A') self.assertTrue(success) # Check if record was removed zone_file = os.path.join(self.network_manager.dns_zones_dir, 'test.cell.zone') with open(zone_file, 'r') as f: content = f.read() self.assertNotIn('192.168.1.100', content) def test_load_dns_records(self): """Test loading DNS records from zone file""" # Create a test zone file zone_file = os.path.join(self.network_manager.dns_zones_dir, 'test.cell.zone') content = """$TTL 3600 @ IN SOA test.cell. admin.test.cell. ( 2024010101 ; Serial 3600 ; Refresh 1800 ; Retry 1209600 ; Expire 3600 ; Minimum TTL ) ; Name servers @ IN NS test.cell. test1 3600 IN A 192.168.1.10 test2 1800 IN CNAME test1 """ with open(zone_file, 'w') as f: f.write(content) records = self.network_manager._load_dns_records('test.cell') self.assertEqual(len(records), 2) self.assertEqual(records[0]['name'], 'test1') self.assertEqual(records[0]['value'], '192.168.1.10') self.assertEqual(records[1]['name'], 'test2') self.assertEqual(records[1]['type'], 'CNAME') def test_get_dhcp_leases(self): """Test getting DHCP leases""" # Create a test leases file leases_file = self.network_manager.dhcp_leases_file content = """1234567890 aa:bb:cc:dd:ee:ff 192.168.1.100 testhost * 1234567891 11:22:33:44:55:66 192.168.1.101 anotherhost * """ with open(leases_file, 'w') as f: f.write(content) leases = self.network_manager.get_dhcp_leases() self.assertEqual(len(leases), 2) self.assertEqual(leases[0]['mac'], 'aa:bb:cc:dd:ee:ff') self.assertEqual(leases[0]['ip'], '192.168.1.100') self.assertEqual(leases[0]['hostname'], 'testhost') self.assertEqual(leases[1]['mac'], '11:22:33:44:55:66') self.assertEqual(leases[1]['ip'], '192.168.1.101') def test_add_dhcp_reservation(self): """Test adding DHCP reservation""" success = self.network_manager.add_dhcp_reservation('aa:bb:cc:dd:ee:ff', '192.168.1.100', 'testhost') self.assertTrue(success) # Check if reservation file was created reservation_file = os.path.join(self.config_dir, 'dhcp', 'reservations.conf') self.assertTrue(os.path.exists(reservation_file)) # Check content with open(reservation_file, 'r') as f: content = f.read() self.assertIn('aa:bb:cc:dd:ee:ff', content) self.assertIn('192.168.1.100', content) self.assertIn('testhost', content) def test_remove_dhcp_reservation(self): """Test removing DHCP reservation""" # Add a reservation first self.network_manager.add_dhcp_reservation('aa:bb:cc:dd:ee:ff', '192.168.1.100', 'testhost') # Remove it success = self.network_manager.remove_dhcp_reservation('aa:bb:cc:dd:ee:ff') self.assertTrue(success) # Check if reservation was removed reservation_file = os.path.join(self.config_dir, 'dhcp', 'reservations.conf') with open(reservation_file, 'r') as f: content = f.read() self.assertNotIn('aa:bb:cc:dd:ee:ff', content) @patch('subprocess.run') def test_get_ntp_status(self, mock_run): """Test getting NTP status""" # Mock NTP service running mock_run.return_value.stdout = 'cell-ntp\n' mock_run.return_value.returncode = 0 status = self.network_manager.get_ntp_status() self.assertTrue(status['running']) self.assertIn('stats', status) @patch('subprocess.run') def test_get_ntp_status_not_running(self, mock_run): """Test getting NTP status when service is not running""" # Mock NTP service not running mock_run.return_value.stdout = '' mock_run.return_value.returncode = 0 status = self.network_manager.get_ntp_status() self.assertFalse(status['running']) self.assertIn('stats', status) @patch('socket.getaddrinfo') def test_test_dns_resolution(self, mock_getaddrinfo): """Test DNS resolution testing""" mock_getaddrinfo.return_value = [(None, None, None, None, ('192.168.1.100', 0))] result = self.network_manager.test_dns_resolution('test.cell') self.assertTrue(result['success']) self.assertIn('192.168.1.100', result['output']) @patch('socket.getaddrinfo') def test_test_dns_resolution_failure(self, mock_getaddrinfo): """Test DNS resolution testing with failure""" import socket mock_getaddrinfo.side_effect = socket.gaierror('NXDOMAIN') result = self.network_manager.test_dns_resolution('nonexistent.cell') self.assertFalse(result['success']) self.assertIn('NXDOMAIN', result['error']) @patch('subprocess.run') def test_test_dhcp_functionality(self, mock_run): """Test DHCP functionality testing""" # Mock DHCP service running mock_run.return_value.stdout = 'cell-dhcp\n' mock_run.return_value.returncode = 0 result = self.network_manager.test_dhcp_functionality() self.assertTrue(result['running']) self.assertIn('leases_count', result) self.assertIn('leases', result) @patch('subprocess.run') def test_test_ntp_functionality(self, mock_run): """Test NTP functionality testing""" # Mock NTP service running with tracking mock_run.return_value.stdout = 'cell-ntp\n' mock_run.return_value.returncode = 0 result = self.network_manager.test_ntp_functionality() self.assertTrue(result['running']) self.assertIn('ntp_test', result) def test_update_dns_zone(self): """Test updating DNS zone""" records = [ {'name': 'test1', 'type': 'A', 'value': '192.168.1.10', 'ttl': 3600}, {'name': 'test2', 'type': 'A', 'value': '192.168.1.11', 'ttl': 3600} ] success = self.network_manager.update_dns_zone('test.cell', records) self.assertTrue(success) # Check if zone file was created zone_file = os.path.join(self.network_manager.dns_zones_dir, 'test.cell.zone') self.assertTrue(os.path.exists(zone_file)) # Check content with open(zone_file, 'r') as f: content = f.read() self.assertIn('test1', content) self.assertIn('test2', content) self.assertIn('192.168.1.10', content) self.assertIn('192.168.1.11', content) class TestBootstrapDnsRecords(unittest.TestCase): """Test bootstrap_dns_records with dynamic IP derivation.""" 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(self.data_dir, exist_ok=True) os.makedirs(self.config_dir, exist_ok=True) self.nm = NetworkManager(self.data_dir, self.config_dir) def tearDown(self): shutil.rmtree(self.test_dir) @patch('subprocess.run') def test_creates_zone_file(self, _mock): self.nm.bootstrap_dns_records('mycell', 'cell') zone_file = os.path.join(self.nm.dns_zones_dir, 'cell.zone') self.assertTrue(os.path.exists(zone_file)) @patch('subprocess.run') def test_contains_wg_server_ip(self, _mock): """Zone file records now use WG server IP (10.0.0.1) not Docker VIPs.""" self.nm.bootstrap_dns_records('mycell', 'cell') zone_file = os.path.join(self.nm.dns_zones_dir, 'cell.zone') content = open(zone_file).read() self.assertIn('10.0.0.1', content) # WG server IP for all services self.assertNotIn('172.20.0.2', content) # Caddy VIP no longer in zone self.assertNotIn('172.20.0.21', content) # Service VIPs no longer in zone @patch('subprocess.run') def test_custom_ip_range_does_not_affect_service_ips(self, _mock): """ip_range is no longer used for service record IPs; WG server IP is used.""" self.nm.bootstrap_dns_records('mycell', 'cell', ip_range='10.5.0.0/24') zone_file = os.path.join(self.nm.dns_zones_dir, 'cell.zone') content = open(zone_file).read() self.assertIn('10.0.0.1', content) # WG server IP self.assertNotIn('10.5.0.2', content) # old caddy pattern gone self.assertNotIn('10.5.0.21', content) # old VIP pattern gone @patch('subprocess.run') def test_idempotent_skips_existing_zone(self, _mock): self.nm.bootstrap_dns_records('mycell', 'cell') zone_file = os.path.join(self.nm.dns_zones_dir, 'cell.zone') mtime1 = os.path.getmtime(zone_file) self.nm.bootstrap_dns_records('mycell', 'cell') mtime2 = os.path.getmtime(zone_file) self.assertEqual(mtime1, mtime2) class TestApplyIpRange(unittest.TestCase): """Test apply_ip_range rewrites DNS zone records.""" 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(self.data_dir, exist_ok=True) os.makedirs(self.config_dir, exist_ok=True) self.nm = NetworkManager(self.data_dir, self.config_dir) def tearDown(self): shutil.rmtree(self.test_dir) @patch('subprocess.run') def test_zone_file_updated_with_wg_server_ip(self, _mock): """apply_ip_range regenerates zone with WG server IP for all service records.""" self.nm.bootstrap_dns_records('mycell', 'cell', '172.20.0.0/16') result = self.nm.apply_ip_range('10.0.0.0/24', 'mycell', 'cell') zone_file = os.path.join(self.nm.dns_zones_dir, 'cell.zone') content = open(zone_file).read() self.assertIn('10.0.0.1', content) # WG server IP for all services self.assertNotIn('172.20.0.2', content) # old Caddy pattern gone self.assertNotIn('172.20.0.21', content) # old VIP pattern gone @patch('subprocess.run') def test_returns_restarted_on_success(self, _mock): self.nm.bootstrap_dns_records('mycell', 'cell', '172.20.0.0/16') result = self.nm.apply_ip_range('10.0.0.0/24', 'mycell', 'cell') self.assertIn('cell-dns (reloaded)', result['restarted']) @patch('subprocess.run') def test_all_standard_records_present(self, _mock): self.nm.apply_ip_range('10.1.2.0/24', 'pictest', 'mycell') zone_file = os.path.join(self.nm.dns_zones_dir, 'mycell.zone') content = open(zone_file).read() # Infrastructure and built-in service names are always generated for host in ('pictest', 'api', 'webui', 'calendar', 'files', 'mail', 'webdav'): self.assertIn(host, content) # Non-built-in names are only generated when a registry is wired self.assertNotIn('webmail', content) @patch('subprocess.run') def test_same_range_updates_zone_without_error(self, _mock): self.nm.bootstrap_dns_records('mycell', 'cell', '172.20.0.0/16') result = self.nm.apply_ip_range('172.20.0.0/16', 'mycell', 'cell') self.assertEqual(result['warnings'], []) class TestCellDnsForwarding(unittest.TestCase): """Test add/remove cell DNS forwarding in Corefile.""" 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(self.data_dir, exist_ok=True) os.makedirs(os.path.join(self.config_dir, 'dns'), exist_ok=True) self.nm = NetworkManager(self.data_dir, self.config_dir) self.corefile = os.path.join(self.config_dir, 'dns', 'Corefile') with open(self.corefile, 'w') as f: f.write('home.cell {\n file /data/home.cell.zone\n log\n}\n\n. {\n forward . 8.8.8.8\n log\n}\n') def tearDown(self): shutil.rmtree(self.test_dir) @patch('subprocess.run') def test_add_cell_dns_forward_appends_block(self, _mock): self.nm.add_cell_dns_forward('remote.cell', '10.1.0.1') with open(self.corefile) as f: content = f.read() self.assertIn('remote.cell', content) self.assertIn('10.1.0.1', content) self.assertIn('forward . 10.1.0.1', content) @patch('subprocess.run') def test_add_cell_dns_forward_idempotent(self, _mock): self.nm.add_cell_dns_forward('remote.cell', '10.1.0.1') self.nm.add_cell_dns_forward('remote.cell', '10.1.0.1') with open(self.corefile) as f: content = f.read() self.assertEqual(content.count('forward . 10.1.0.1'), 1) @patch('subprocess.run') def test_remove_cell_dns_forward_cleans_block(self, _mock): self.nm.add_cell_dns_forward('remote.cell', '10.1.0.1') self.nm.remove_cell_dns_forward('remote.cell') with open(self.corefile) as f: content = f.read() self.assertNotIn('remote.cell', content) self.assertNotIn('10.1.0.1', content) @patch('subprocess.run') def test_remove_nonexistent_forward_does_not_error(self, _mock): # Removing a domain that was never added must not raise and must not # leave the nonexistent domain in the regenerated Corefile. result = self.nm.remove_cell_dns_forward('nonexistent.cell') after = open(self.corefile).read() self.assertNotIn('nonexistent.cell', after) # The Corefile is regenerated (new canonical format) — that's correct. class TestUpdateSplitHorizonZone(unittest.TestCase): """Test update_split_horizon_zone writes zone file and Corefile.""" 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, 'dns'), exist_ok=True) self.nm = NetworkManager(self.data_dir, self.config_dir) def tearDown(self): shutil.rmtree(self.test_dir) @patch('subprocess.run') def test_creates_zone_file_with_wildcard(self, _mock): """Zone file must contain wildcard A record pointing to caddy_ip.""" self.nm.update_split_horizon_zone('pic1.pic.ngo', '172.20.0.2') zone_path = os.path.join(self.data_dir, 'dns', 'pic1.pic.ngo.zone') self.assertTrue(os.path.exists(zone_path)) content = open(zone_path).read() self.assertIn('172.20.0.2', content) @patch('subprocess.run') def test_corefile_contains_split_horizon_block(self, _mock): """Corefile must reference the new zone file.""" self.nm.update_split_horizon_zone('pic1.pic.ngo', '172.20.0.2') corefile = os.path.join(self.config_dir, 'dns', 'Corefile') self.assertTrue(os.path.exists(corefile)) content = open(corefile).read() self.assertIn('pic1.pic.ngo {', content) self.assertIn('file /data/pic1.pic.ngo.zone', content) @patch('subprocess.run') def test_returns_true_on_success(self, _mock): ok = self.nm.update_split_horizon_zone('pic1.pic.ngo', '172.20.0.2') self.assertTrue(ok) @patch('subprocess.run') def test_sends_sigusr1_to_coredns(self, mock_run): """CoreDNS reload (SIGUSR1) must be triggered after writing.""" mock_run.return_value = MagicMock(returncode=0, stderr='') self.nm.update_split_horizon_zone('pic1.pic.ngo', '172.20.0.2') calls = [str(c) for c in mock_run.call_args_list] self.assertTrue(any('SIGUSR1' in c for c in calls)) @patch('subprocess.run') def test_removes_stale_service_records_when_primary_is_parent(self, _mock): """Stale LAN service names (api, calendar…) are removed from a parent zone. A registry that knows about calendar and files is required so those names appear in the stale set. """ from unittest.mock import MagicMock registry = MagicMock() registry.get_caddy_routes.return_value = [ {'service_id': 'calendar', 'subdomain': 'calendar', 'backend': 'cell-radicale:5232', 'extra_subdomains': [], 'extra_backends': {}}, {'service_id': 'files', 'subdomain': 'files', 'backend': 'cell-filegator:8080', 'extra_subdomains': [], 'extra_backends': {}}, ] self.nm._service_registry = registry # Bootstrap a pic.ngo zone with service records (wrong internal zone name) stale_records = [ {'name': 'pic2', 'type': 'A', 'value': '10.0.0.1'}, {'name': 'api', 'type': 'A', 'value': '10.0.0.1'}, {'name': 'calendar','type': 'A', 'value': '10.0.0.1'}, {'name': 'files', 'type': 'A', 'value': '10.0.0.1'}, ] self.nm.update_dns_zone('pic.ngo', stale_records) # update_split_horizon_zone should strip api/calendar/files from pic.ngo self.nm.update_split_horizon_zone( 'pic2.pic.ngo', '172.20.0.2', primary_domain='pic.ngo' ) content = open(os.path.join(self.data_dir, 'dns', 'pic.ngo.zone')).read() self.assertNotIn('calendar', content) self.assertNotIn('\napi ', content) self.assertNotIn('\nfiles ', content) # Non-stale record (pic2 is the cell_name, not in _stale set) survives # but api/calendar/files are gone self.assertIn('172.20.0.2', open( os.path.join(self.data_dir, 'dns', 'pic2.pic.ngo.zone')).read()) @patch('subprocess.run') def test_no_stale_cleanup_when_primary_not_parent(self, _mock): """When primary_domain is unrelated, no zone file is touched.""" stale_records = [{'name': 'calendar', 'type': 'A', 'value': '10.0.0.1'}] self.nm.update_dns_zone('cell', stale_records) self.nm.update_split_horizon_zone( 'pic2.pic.ngo', '172.20.0.2', primary_domain='cell' ) # cell zone is untouched content = open(os.path.join(self.data_dir, 'dns', 'cell.zone')).read() self.assertIn('calendar', content) class TestApplyCellName(unittest.TestCase): """Tests for apply_cell_name — hostname rename in primary DNS zone.""" 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, 'dns'), exist_ok=True) self.nm = NetworkManager(self.data_dir, self.config_dir) def tearDown(self): shutil.rmtree(self.test_dir) def _write_zone(self, name: str, content: str): path = os.path.join(self.data_dir, 'dns', f'{name}.zone') with open(path, 'w') as f: f.write(content) return path @patch('subprocess.run') def test_renames_hostname_in_primary_zone(self, _mock): """Old cell name is replaced with new name in the primary zone.""" self._write_zone('cell', ( '$ORIGIN cell.\n' '@ 300 IN SOA ns1 admin 1 3600 900 86400 300\n' '@ 300 IN NS ns1\n' 'oldname 300 IN A 172.20.0.2\n' 'api 300 IN A 172.20.0.10\n' )) self.nm.apply_cell_name('oldname', 'newname', reload=False) content = open(os.path.join(self.data_dir, 'dns', 'cell.zone')).read() self.assertIn('newname', content) self.assertNotIn('oldname', content) @patch('subprocess.run') def test_does_not_corrupt_split_horizon_zone(self, _mock): """A multi-label DDNS zone (e.g. pic2.pic.ngo.zone) must not be touched.""" sh_path = self._write_zone('pic2.pic.ngo', ( '$ORIGIN pic2.pic.ngo.\n' '@ 300 IN SOA ns1 admin 1 3600 900 86400 300\n' '@ 300 IN NS ns1\n' '@ 300 IN A 172.20.0.2\n' '* 300 IN A 172.20.0.2\n' )) self._write_zone('cell', ( '$ORIGIN cell.\n' '@ 300 IN SOA ns1 admin 1 3600 900 86400 300\n' 'oldname 300 IN A 172.20.0.2\n' )) self.nm.apply_cell_name('oldname', 'newname', reload=False) # Split-horizon zone must be unchanged (wildcard not renamed) sh_content = open(sh_path).read() self.assertNotIn('newname', sh_content) self.assertIn('* 300 IN A 172.20.0.2', sh_content) @patch('subprocess.run') def test_wildcard_not_treated_as_hostname(self, _mock): """Wildcard record in a zone must never be detected as the cell hostname.""" zone_path = self._write_zone('cell', ( '$ORIGIN cell.\n' '@ 300 IN SOA ns1 admin 1 3600 900 86400 300\n' '@ 300 IN A 172.20.0.2\n' '* 300 IN A 172.20.0.2\n' )) self.nm.apply_cell_name('', 'newname', reload=False) content = open(zone_path).read() # Wildcard must remain; 'newname' must not appear self.assertIn('* 300 IN A', content) self.assertNotIn('newname', content) @patch('subprocess.run') def test_skips_zone_with_local_in_name(self, _mock): """Zones with 'local' in the filename are ignored.""" local_path = self._write_zone('home.local', ( '$ORIGIN home.local.\n' 'oldname 300 IN A 172.20.0.2\n' )) self.nm.apply_cell_name('oldname', 'newname', reload=False) content = open(local_path).read() self.assertIn('oldname', content) self.assertNotIn('newname', content) class TestUpdateSplitHorizonZoneStaleCleanup(unittest.TestCase): """Tests for stale split-horizon zone deletion in update_split_horizon_zone.""" 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, 'dns'), exist_ok=True) self.nm = NetworkManager(self.data_dir, self.config_dir) def tearDown(self): shutil.rmtree(self.test_dir) @patch('subprocess.run') def test_deletes_old_cell_zone_same_tld(self, _mock): """When renaming pic3.pic.ngo → pic2.pic.ngo the old zone file is removed.""" old_zone = os.path.join(self.data_dir, 'dns', 'pic3.pic.ngo.zone') with open(old_zone, 'w') as f: f.write('@ 300 IN A 172.20.0.2\n') self.nm.update_split_horizon_zone('pic2.pic.ngo', '172.20.0.2') self.assertFalse(os.path.exists(old_zone), 'stale pic3.pic.ngo.zone should be deleted') new_zone = os.path.join(self.data_dir, 'dns', 'pic2.pic.ngo.zone') self.assertTrue(os.path.exists(new_zone)) @patch('subprocess.run') def test_keeps_zone_for_different_tld(self, _mock): """Zone files under a different TLD are not deleted.""" other_zone = os.path.join(self.data_dir, 'dns', 'myhost.example.com.zone') with open(other_zone, 'w') as f: f.write('@ 300 IN A 1.2.3.4\n') self.nm.update_split_horizon_zone('pic2.pic.ngo', '172.20.0.2') self.assertTrue(os.path.exists(other_zone), 'unrelated zone must not be deleted') @patch('subprocess.run') def test_keeps_current_effective_zone(self, _mock): """The current effective_domain zone file is never deleted.""" self.nm.update_split_horizon_zone('pic2.pic.ngo', '172.20.0.2') current_zone = os.path.join(self.data_dir, 'dns', 'pic2.pic.ngo.zone') self.assertTrue(os.path.exists(current_zone)) class TestGetWgServerIp(unittest.TestCase): """_get_wg_server_ip must read from wg0.conf and fall back to 10.0.0.1. Regression guard: _bootstrap_dns used to pass 172.20.0.2 (Docker bridge IP) to update_split_horizon_zone. WireGuard peers cannot reach that IP; the zone must use the WireGuard server IP (e.g. 10.0.0.1) so VPN clients can reach Caddy. """ 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, 'dns'), exist_ok=True) self.nm = NetworkManager(self.data_dir, self.config_dir) def tearDown(self): shutil.rmtree(self.test_dir) def _write_wg_conf(self, address: str) -> None: wg_dir = os.path.join(self.config_dir, 'wireguard', 'wg_confs') os.makedirs(wg_dir, exist_ok=True) with open(os.path.join(wg_dir, 'wg0.conf'), 'w') as f: f.write(f'[Interface]\nAddress = {address}\nListenPort = 51820\n') def test_reads_address_from_wg0_conf(self): self._write_wg_conf('10.0.0.1/24') self.assertEqual(self.nm._get_wg_server_ip(), '10.0.0.1') def test_reads_non_default_address(self): self._write_wg_conf('10.8.0.1/16') self.assertEqual(self.nm._get_wg_server_ip(), '10.8.0.1') def test_falls_back_to_10_0_0_1_when_conf_missing(self): self.assertEqual(self.nm._get_wg_server_ip(), '10.0.0.1') def test_split_horizon_zone_uses_wg_ip_not_docker_bridge(self): """update_split_horizon_zone called with WG IP writes that IP in zone file. This is the correct call pattern from _bootstrap_dns: pass the WireGuard server IP, not 172.20.0.x (Docker bridge IP unreachable from VPN peers). """ self._write_wg_conf('10.0.0.1/24') wg_ip = self.nm._get_wg_server_ip() self.assertEqual(wg_ip, '10.0.0.1', 'WireGuard IP must be read from wg0.conf, not be a Docker bridge address') with patch('subprocess.run'): self.nm.update_split_horizon_zone('pic1.pic.ngo', wg_ip) zone_path = os.path.join(self.data_dir, 'dns', 'pic1.pic.ngo.zone') content = open(zone_path).read() self.assertIn('10.0.0.1', content) self.assertNotIn('172.20.0', content, 'Zone must not contain Docker bridge IP — VPN peers cannot reach it') if __name__ == '__main__': unittest.main()