Files
pic/tests/test_caddy_registry_integration.py
T
roof 0bfe95320b
Unit Tests / test (push) Successful in 11m31s
feat: Phase 2 — remove builtins layer, ServiceRegistry is installed-only
Builtins (email/calendar/files) are no longer baked into the API image.
ServiceRegistry now only knows about installed store services. When nothing
is installed, Caddy and DNS get no service routes — no hardcoded fallback.

Changes:
- service_registry.py: remove _BUILTINS_DIR, _builtin_ids, _builtin_manifest,
  _load_manifest; get() and list_all() now delegate entirely to installed services
- caddy_manager.py: remove _build_core_service_routes(); remove hardcoded
  fallback pairs from _http01_service_pairs(); empty registry → api block only
- network_manager.py: _get_service_subdomains() returns [] when no registry
- api/services/builtins/: deleted (email, calendar, files manifests)
- Tests updated throughout: removed builtin-dependent assertions, added
  installed-service fixtures, updated fallback expectations to api-only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 08:53:44 -04:00

533 lines
23 KiB
Python

"""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()