Files
pic/tests/test_network_manager.py
T
roof aa1e5c41ec
Unit Tests / test (push) Successful in 12m6s
test: raise coverage 68.7% -> ~80.4%; add ~250 tests for new egress/DDNS/network paths
Coverage was below acceptable levels and several newly-added code paths
(sshuttle egress, proxy egress, DDNS provider stubs, DNS overview route,
peer-registry provisioning) had zero test coverage.

~250 new unit tests are added across 16 new test files. Existing test files
are updated to match refactored interfaces (DHCP removed, constants
introduced, network_manager restructured). .coveragerc is added to pin the
source mapping and the 70% floor so regressions are caught at commit time.

tests/test_enhanced_api.py was previously living in api/ (wrong location)
and is moved to tests/ where it belongs.

Integration test files are updated to remove references to DHCP endpoints
and add coverage for the new DNS overview and DDNS sync endpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:03:39 -04:00

633 lines
27 KiB
Python

#!/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))
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')
@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_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()