"""Integration tests for registry-driven CaddyManager and NetworkManager routing. These tests cover the new registry path introduced in Step 5 of the PIC Services Architecture. The no-registry (fallback) paths are already covered by test_caddy_manager.py and test_network_manager.py. """ import os import sys import shutil import tempfile import unittest from unittest.mock import MagicMock sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api')) from caddy_manager import CaddyManager # noqa: E402 from network_manager import NetworkManager # noqa: E402 # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- def _mgr_with_registry(registry=None): """Build a CaddyManager wired to an optional mock registry.""" cm = MagicMock() cm.get_identity.return_value = {} return CaddyManager(config_manager=cm, service_registry=registry) def _mock_registry(): """Return a mock ServiceRegistry that reproduces 3 store service routes.""" reg = MagicMock() reg.get_caddy_routes.return_value = [ { 'service_id': 'calendar', 'subdomain': 'calendar', 'backend': 'cell-radicale:5232', 'extra_subdomains': [], 'extra_backends': {}, }, { 'service_id': 'email', 'subdomain': 'mail', 'backend': 'cell-rainloop:8888', 'extra_subdomains': ['webmail'], 'extra_backends': {}, }, { 'service_id': 'files', 'subdomain': 'files', 'backend': 'cell-filegator:8080', 'extra_subdomains': ['webdav'], 'extra_backends': {'webdav': 'cell-webdav:80'}, }, ] return reg def _nm(registry=None): """Build a NetworkManager backed by temp dirs and an optional mock registry.""" tmpdir = tempfile.mkdtemp() nm = NetworkManager( data_dir=os.path.join(tmpdir, 'data'), config_dir=os.path.join(tmpdir, 'config'), service_registry=registry, ) nm._tmpdir = tmpdir # stash so the caller can clean up return nm # --------------------------------------------------------------------------- # TestBuildRegistryServiceRoutes # --------------------------------------------------------------------------- class TestBuildRegistryServiceRoutes(unittest.TestCase): def test_returns_api_only_when_no_registry(self): """service_registry=None produces only the @api block.""" mgr = _mgr_with_registry(registry=None) domain = 'alpha.pic.ngo' result = mgr._build_registry_service_routes(domain) self.assertIn('@api host api.alpha.pic.ngo', result) self.assertIn('reverse_proxy cell-api:3000', result) self.assertNotIn('@calendar', result) self.assertNotIn('@mail', result) def test_returns_api_only_when_registry_empty(self): """An empty route list from the registry produces only the @api block.""" reg = MagicMock() reg.get_caddy_routes.return_value = [] mgr = _mgr_with_registry(registry=reg) domain = 'alpha.pic.ngo' result = mgr._build_registry_service_routes(domain) self.assertIn('@api host api.alpha.pic.ngo', result) self.assertIn('reverse_proxy cell-api:3000', result) self.assertNotIn('@calendar', result) self.assertNotIn('@mail', result) def test_returns_api_only_on_registry_error(self): """When get_caddy_routes raises, only the @api block is produced.""" reg = MagicMock() reg.get_caddy_routes.side_effect = Exception('registry unavailable') mgr = _mgr_with_registry(registry=reg) domain = 'alpha.pic.ngo' result = mgr._build_registry_service_routes(domain) self.assertIn('@api host api.alpha.pic.ngo', result) self.assertIn('reverse_proxy cell-api:3000', result) self.assertNotIn('@calendar', result) self.assertNotIn('@mail', result) def test_single_service_no_extras(self): """One service with no extra_subdomains produces one matcher + handle + api block.""" reg = MagicMock() reg.get_caddy_routes.return_value = [ { 'service_id': 'calendar', 'subdomain': 'calendar', 'backend': 'cell-radicale:5232', 'extra_subdomains': [], 'extra_backends': {}, } ] mgr = _mgr_with_registry(registry=reg) result = mgr._build_registry_service_routes('test.cell') self.assertIn('@calendar host calendar.test.cell', result) self.assertIn('reverse_proxy cell-radicale:5232', result) self.assertIn('@api host api.test.cell', result) self.assertIn('reverse_proxy cell-api:3000', result) # Only two named-matcher definition lines: @calendar and @api matcher_lines = [l for l in result.splitlines() if l.strip().startswith('@') and 'host' in l] self.assertEqual(len(matcher_lines), 2) def test_extra_subdomain_same_backend(self): """An extra_subdomain NOT in extra_backends shares the primary matcher host line.""" reg = MagicMock() reg.get_caddy_routes.return_value = [ { 'service_id': 'email', 'subdomain': 'mail', 'backend': 'cell-rainloop:8888', 'extra_subdomains': ['webmail'], 'extra_backends': {}, # webmail not listed → shares backend } ] mgr = _mgr_with_registry(registry=reg) result = mgr._build_registry_service_routes('test.cell') # Both subdomains appear in the same host matcher line self.assertIn('@mail host mail.test.cell webmail.test.cell', result) # Only one reverse_proxy for cell-rainloop (shared block) self.assertEqual(result.count('reverse_proxy cell-rainloop:8888'), 1) # No separate @webmail block self.assertNotIn('@webmail host', result) def test_extra_subdomain_different_backend(self): """An extra_subdomain listed in extra_backends gets its own matcher + handle block.""" reg = MagicMock() reg.get_caddy_routes.return_value = [ { 'service_id': 'files', 'subdomain': 'files', 'backend': 'cell-filegator:8080', 'extra_subdomains': ['webdav'], 'extra_backends': {'webdav': 'cell-webdav:80'}, } ] mgr = _mgr_with_registry(registry=reg) result = mgr._build_registry_service_routes('test.cell') # files gets its own block (webdav not in shared list) self.assertIn('@files host files.test.cell', result) self.assertIn('reverse_proxy cell-filegator:8080', result) # webdav gets a separate block self.assertIn('@webdav host webdav.test.cell', result) self.assertIn('reverse_proxy cell-webdav:80', result) # webdav must NOT appear in the @files host line files_line = [l for l in result.splitlines() if '@files host' in l][0] self.assertNotIn('webdav', files_line) def test_api_always_appended(self): """The @api block is always the last block even when registry has no api entry.""" reg = _mock_registry() mgr = _mgr_with_registry(registry=reg) result = mgr._build_registry_service_routes('alpha.pic.ngo') self.assertIn('@api host api.alpha.pic.ngo', result) self.assertIn('reverse_proxy cell-api:3000', result) # api block is at the end api_idx = result.rfind('@api') other_matchers = ['@calendar', '@mail', '@files', '@webdav'] for m in other_matchers: self.assertLess(result.index(m), api_idx, f'{m} should appear before @api') def test_api_not_duplicated_when_registry_returns_api(self): """Even if registry somehow returns an 'api' route, the injected api block is cell-api:3000.""" reg = MagicMock() reg.get_caddy_routes.return_value = [ { 'service_id': 'api', 'subdomain': 'api', 'backend': 'cell-other:9999', # wrong backend — should be overridden 'extra_subdomains': [], 'extra_backends': {}, } ] mgr = _mgr_with_registry(registry=reg) result = mgr._build_registry_service_routes('test.cell') # The infrastructure api block is always appended with the canonical backend self.assertIn('reverse_proxy cell-api:3000', result) # api host matcher appears at least once (from registry AND from append) self.assertGreaterEqual(result.count('@api host api.test.cell'), 1) # --------------------------------------------------------------------------- # TestHttp01ServicePairs # --------------------------------------------------------------------------- class TestHttp01ServicePairs(unittest.TestCase): def test_pairs_from_registry(self): """With the 3 builtins the pairs list matches expected (subdomain, backend) tuples.""" reg = _mock_registry() mgr = _mgr_with_registry(registry=reg) pairs = mgr._http01_service_pairs() pairs_dict = dict(pairs) self.assertEqual(pairs_dict['calendar'], 'cell-radicale:5232') self.assertEqual(pairs_dict['mail'], 'cell-rainloop:8888') self.assertEqual(pairs_dict['webmail'], 'cell-rainloop:8888') self.assertEqual(pairs_dict['files'], 'cell-filegator:8080') self.assertEqual(pairs_dict['webdav'], 'cell-webdav:80') self.assertEqual(pairs_dict['api'], 'cell-api:3000') def test_webdav_gets_own_backend(self): """webdav must map to cell-webdav:80, not to cell-filegator:8080.""" reg = _mock_registry() mgr = _mgr_with_registry(registry=reg) pairs = mgr._http01_service_pairs() webdav_entry = next((b for s, b in pairs if s == 'webdav'), None) self.assertIsNotNone(webdav_entry) self.assertEqual(webdav_entry, 'cell-webdav:80') self.assertNotEqual(webdav_entry, 'cell-filegator:8080') def test_only_api_when_no_registry(self): """Without a registry only the api pair is returned.""" mgr = _mgr_with_registry(registry=None) pairs = mgr._http01_service_pairs() subdomains = [s for s, _ in pairs] self.assertIn('api', subdomains) self.assertNotIn('calendar', subdomains) self.assertNotIn('mail', subdomains) self.assertNotIn('files', subdomains) def test_only_api_on_registry_error(self): """When get_caddy_routes raises, only the api pair is present.""" reg = MagicMock() reg.get_caddy_routes.side_effect = RuntimeError('boom') mgr = _mgr_with_registry(registry=reg) pairs = mgr._http01_service_pairs() subdomains = [s for s, _ in pairs] self.assertIn('api', subdomains) self.assertNotIn('calendar', subdomains) # --------------------------------------------------------------------------- # TestCaddyfileWithRegistry # --------------------------------------------------------------------------- class TestCaddyfileWithRegistry(unittest.TestCase): def _generate(self, domain_mode, cell_name='alpha', domain_name=None, registry=None, services=None): reg = registry if registry is not None else _mock_registry() mgr = _mgr_with_registry(registry=reg) identity = {'cell_name': cell_name, 'domain_mode': domain_mode} if domain_name: identity['domain_name'] = domain_name return mgr.generate_caddyfile(identity, services or []) def test_pic_ngo_with_registry_has_correct_routes(self): """pic_ngo Caddyfile has all service matchers with correct subdomains and backends.""" out = self._generate('pic_ngo', cell_name='alpha') # calendar self.assertIn('@calendar host calendar.alpha.pic.ngo', out) self.assertIn('reverse_proxy cell-radicale:5232', out) # mail + webmail share one matcher self.assertIn('@mail host mail.alpha.pic.ngo webmail.alpha.pic.ngo', out) self.assertIn('reverse_proxy cell-rainloop:8888', out) # files self.assertIn('@files host files.alpha.pic.ngo', out) self.assertIn('reverse_proxy cell-filegator:8080', out) # webdav separate block self.assertIn('@webdav host webdav.alpha.pic.ngo', out) self.assertIn('reverse_proxy cell-webdav:80', out) # api always present self.assertIn('@api host api.alpha.pic.ngo', out) self.assertIn('reverse_proxy cell-api:3000', out) def test_cloudflare_with_registry_uses_registry_routes(self): """cloudflare Caddyfile routes are sourced from registry, not hardcoded.""" out = self._generate('cloudflare', cell_name='beta', domain_name='example.com') self.assertIn('@calendar host calendar.example.com', out) self.assertIn('@mail host mail.example.com webmail.example.com', out) self.assertIn('@files host files.example.com', out) self.assertIn('@webdav host webdav.example.com', out) self.assertIn('@api host api.example.com', out) # Correct DNS plugin block is still present self.assertIn('dns cloudflare {$CF_API_TOKEN}', out) def test_duckdns_with_registry_uses_registry_routes(self): """duckdns Caddyfile routes are sourced from registry.""" out = self._generate('duckdns', cell_name='gamma') self.assertIn('@calendar host calendar.gamma.duckdns.org', out) self.assertIn('@api host api.gamma.duckdns.org', out) self.assertIn('dns duckdns {$DUCKDNS_TOKEN}', out) def test_http01_with_registry_has_per_host_blocks(self): """http01 Caddyfile has individual per-host blocks for every service subdomain.""" out = self._generate('http01', cell_name='delta', domain_name='delta.noip.me') self.assertIn('calendar.delta.noip.me {', out) self.assertIn('mail.delta.noip.me {', out) self.assertIn('webmail.delta.noip.me {', out) self.assertIn('files.delta.noip.me {', out) self.assertIn('webdav.delta.noip.me {', out) self.assertIn('api.delta.noip.me {', out) # Correct backends self.assertIn('reverse_proxy cell-radicale:5232', out) self.assertIn('reverse_proxy cell-rainloop:8888', out) self.assertIn('reverse_proxy cell-filegator:8080', out) self.assertIn('reverse_proxy cell-webdav:80', out) def test_pic_ngo_api_only_when_registry_empty(self): """pic_ngo emits only the api block when registry returns empty list.""" reg = MagicMock() reg.get_caddy_routes.return_value = [] out = self._generate('pic_ngo', cell_name='alpha', registry=reg) self.assertIn('@api host api.alpha.pic.ngo', out) self.assertNotIn('@calendar', out) self.assertNotIn('@mail', out) # --------------------------------------------------------------------------- # TestNetworkManagerGetServiceSubdomains # --------------------------------------------------------------------------- class TestNetworkManagerGetServiceSubdomains(unittest.TestCase): def setUp(self): self.managers = [] def tearDown(self): for nm in self.managers: shutil.rmtree(nm._tmpdir, ignore_errors=True) def _make(self, registry=None): nm = _nm(registry=registry) self.managers.append(nm) return nm def test_no_registry_returns_empty(self): """Without a registry an empty list is returned.""" nm = self._make(registry=None) subs = nm._get_service_subdomains() self.assertEqual(subs, []) def test_registry_returns_all_subdomains(self): """Primary + extra_subdomains from all routes are returned.""" reg = _mock_registry() nm = self._make(registry=reg) subs = nm._get_service_subdomains() # calendar (primary), mail (primary), webmail (extra), files (primary), webdav (extra) for expected in ('calendar', 'mail', 'webmail', 'files', 'webdav'): self.assertIn(expected, subs) def test_registry_error_returns_empty(self): """When get_caddy_routes raises, an empty list is returned.""" reg = MagicMock() reg.get_caddy_routes.side_effect = Exception('broken registry') nm = self._make(registry=reg) subs = nm._get_service_subdomains() self.assertEqual(subs, []) def test_registry_extra_subdomains_included(self): """extra_subdomains from each route are included in the returned list.""" reg = MagicMock() reg.get_caddy_routes.return_value = [ { 'service_id': 'files', 'subdomain': 'files', 'backend': 'cell-filegator:8080', 'extra_subdomains': ['webdav', 'dav'], 'extra_backends': {}, } ] nm = self._make(registry=reg) subs = nm._get_service_subdomains() self.assertIn('files', subs) self.assertIn('webdav', subs) self.assertIn('dav', subs) def test_build_dns_records_with_registry(self): """All registry subdomains appear as A records in _build_dns_records output.""" reg = _mock_registry() nm = self._make(registry=reg) # Override WG IP lookup so we get a predictable value nm._get_wg_server_ip = lambda: '10.0.0.1' records = nm._build_dns_records('mycell', '172.20.0.0/16') names = [r['name'] for r in records] for expected in ('mycell', 'api', 'webui', 'calendar', 'mail', 'webmail', 'files', 'webdav'): self.assertIn(expected, names, f'{expected!r} should be in DNS records but is not') # All records must point to the WG server IP for r in records: self.assertEqual(r['value'], '10.0.0.1') self.assertEqual(r['type'], 'A') # --------------------------------------------------------------------------- # TestNetworkManagerStaleSet # --------------------------------------------------------------------------- class TestNetworkManagerStaleSet(unittest.TestCase): """Verify that registry subdomains drive stale record cleanup in update_split_horizon_zone.""" def setUp(self): self.test_dir = tempfile.mkdtemp() data_dir = os.path.join(self.test_dir, 'data') config_dir = os.path.join(self.test_dir, 'config') os.makedirs(os.path.join(data_dir, 'dns'), exist_ok=True) os.makedirs(os.path.join(config_dir, 'dns'), exist_ok=True) self.reg = _mock_registry() self.nm = NetworkManager( data_dir=data_dir, config_dir=config_dir, service_registry=self.reg, ) def tearDown(self): shutil.rmtree(self.test_dir, ignore_errors=True) def _write_zone(self, zone_name: str, content: str): path = os.path.join(self.nm.dns_zones_dir, f'{zone_name}.zone') with open(path, 'w') as f: f.write(content) def test_stale_set_includes_registry_subdomains(self): """Registry subdomains (calendar, mail, webmail, files, webdav) are treated as stale service records and removed from the parent zone during update_split_horizon_zone.""" import subprocess # Build a parent zone with stale service records that the registry knows about stale_records = [ {'name': 'pic2', 'type': 'A', 'value': '10.0.0.1'}, {'name': 'api', 'type': 'A', 'value': '10.0.0.1'}, {'name': 'webui', 'type': 'A', 'value': '10.0.0.1'}, {'name': 'calendar', 'type': 'A', 'value': '10.0.0.1'}, {'name': 'mail', 'type': 'A', 'value': '10.0.0.1'}, {'name': 'webmail', 'type': 'A', 'value': '10.0.0.1'}, {'name': 'files', 'type': 'A', 'value': '10.0.0.1'}, {'name': 'webdav', 'type': 'A', 'value': '10.0.0.1'}, ] from unittest.mock import patch with patch('subprocess.run'): self.nm.update_dns_zone('pic.ngo', stale_records) self.nm.update_split_horizon_zone( 'pic2.pic.ngo', '172.20.0.2', primary_domain='pic.ngo' ) parent_zone = os.path.join(self.nm.dns_zones_dir, 'pic.ngo.zone') content = open(parent_zone).read() # All registry subdomains must be gone for stale in ('api', 'webui', 'calendar', 'mail', 'webmail', 'files', 'webdav'): # Check that no line *starts* with the stale name (to avoid false positives # on SOA/NS lines that may contain the zone name as a suffix) lines_with_stale = [ l for l in content.splitlines() if l.startswith(stale + ' ') or l.startswith(stale + '\t') ] self.assertEqual( lines_with_stale, [], f'Stale record {stale!r} should have been removed from pic.ngo zone' ) def test_stale_set_uses_registry_not_hardcoded(self): """When a registry provides a custom subdomain, it is treated as stale too.""" custom_reg = MagicMock() custom_reg.get_caddy_routes.return_value = [ { 'service_id': 'chat', 'subdomain': 'chat', 'backend': 'cell-chat:9000', 'extra_subdomains': ['im'], 'extra_backends': {}, } ] data_dir = os.path.join(self.test_dir, 'data2') config_dir = os.path.join(self.test_dir, 'config2') os.makedirs(os.path.join(data_dir, 'dns'), exist_ok=True) os.makedirs(os.path.join(config_dir, 'dns'), exist_ok=True) nm = NetworkManager(data_dir=data_dir, config_dir=config_dir, service_registry=custom_reg) stale_records = [ {'name': 'pic3', 'type': 'A', 'value': '10.0.0.1'}, {'name': 'chat', 'type': 'A', 'value': '10.0.0.1'}, {'name': 'im', 'type': 'A', 'value': '10.0.0.1'}, ] from unittest.mock import patch with patch('subprocess.run'): nm.update_dns_zone('pic.ngo', stale_records) nm.update_split_horizon_zone( 'pic3.pic.ngo', '172.20.0.2', primary_domain='pic.ngo' ) parent_zone = os.path.join(nm.dns_zones_dir, 'pic.ngo.zone') content = open(parent_zone).read() for stale in ('chat', 'im'): lines_with_stale = [ l for l in content.splitlines() if l.startswith(stale + ' ') or l.startswith(stale + '\t') ] self.assertEqual( lines_with_stale, [], f'Custom registry subdomain {stale!r} should have been removed' ) if __name__ == '__main__': unittest.main()