Phase 2: caddy_manager — Caddyfile generation, health monitor, DNS-01 support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
"""Tests for CaddyManager — Caddyfile generation per domain mode plus
|
||||
admin-API reload, health check, and consecutive-failure bookkeeping.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import requests
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'api'))
|
||||
|
||||
from caddy_manager import CaddyManager # noqa: E402
|
||||
|
||||
|
||||
def _mgr(tmpdir=None, identity=None):
|
||||
"""Build a CaddyManager backed by a mock config_manager."""
|
||||
cm = MagicMock()
|
||||
cm.get_identity.return_value = identity or {}
|
||||
mgr = CaddyManager(
|
||||
config_manager=cm,
|
||||
data_dir=tmpdir or '/tmp/pic-test-data',
|
||||
config_dir=tmpdir or '/tmp/pic-test-config',
|
||||
)
|
||||
return mgr
|
||||
|
||||
|
||||
CALENDAR_ROUTE = (
|
||||
"handle /calendar* {\n"
|
||||
" reverse_proxy cell-radicale:5232\n"
|
||||
"}"
|
||||
)
|
||||
FILES_ROUTE = (
|
||||
"handle /files* {\n"
|
||||
" reverse_proxy cell-filegator:8080\n"
|
||||
"}"
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateCaddyfileLan(unittest.TestCase):
|
||||
def test_lan_mode_has_auto_https_off_and_no_acme(self):
|
||||
mgr = _mgr()
|
||||
identity = {'cell_name': 'mycell', 'domain_mode': 'lan'}
|
||||
out = mgr.generate_caddyfile(identity, [])
|
||||
self.assertIn('auto_https off', out)
|
||||
# No ACME anywhere
|
||||
self.assertNotIn('acme_ca', out)
|
||||
self.assertNotIn('acme_email', out)
|
||||
self.assertNotIn('dns pic_ngo', out)
|
||||
self.assertNotIn('dns cloudflare', out)
|
||||
# Internal-CA TLS pair
|
||||
self.assertIn('tls /etc/caddy/internal/cert.pem '
|
||||
'/etc/caddy/internal/key.pem', out)
|
||||
# Cell hostname plus virtual IP listener
|
||||
self.assertIn('http://mycell.cell', out)
|
||||
self.assertIn('http://172.20.0.2:80', out)
|
||||
|
||||
|
||||
class TestGenerateCaddyfilePicNgo(unittest.TestCase):
|
||||
def test_pic_ngo_has_dns_plugin_and_wildcard(self):
|
||||
mgr = _mgr()
|
||||
identity = {'cell_name': 'alpha', 'domain_mode': 'pic_ngo'}
|
||||
out = mgr.generate_caddyfile(identity, [])
|
||||
self.assertIn('dns pic_ngo', out)
|
||||
self.assertIn('*.alpha.pic.ngo', out)
|
||||
self.assertIn('alpha.pic.ngo', out)
|
||||
self.assertIn('{$PIC_NGO_DDNS_TOKEN}', out)
|
||||
self.assertIn('{$PIC_NGO_DDNS_API}', out)
|
||||
self.assertIn('email admin@alpha.pic.ngo', out)
|
||||
# ACME staging hook
|
||||
self.assertIn('acme_ca {$ACME_CA_URL}', out)
|
||||
|
||||
|
||||
class TestGenerateCaddyfileCloudflare(unittest.TestCase):
|
||||
def test_cloudflare_has_dns_cloudflare(self):
|
||||
mgr = _mgr()
|
||||
identity = {
|
||||
'cell_name': 'beta',
|
||||
'domain_mode': 'cloudflare',
|
||||
'custom_domain': 'example.com',
|
||||
}
|
||||
out = mgr.generate_caddyfile(identity, [])
|
||||
self.assertIn('dns cloudflare {$CF_API_TOKEN}', out)
|
||||
self.assertIn('*.example.com', out)
|
||||
self.assertIn('email {$ACME_EMAIL}', out)
|
||||
self.assertIn('acme_ca {$ACME_CA_URL}', out)
|
||||
|
||||
|
||||
class TestGenerateCaddyfileDuckDns(unittest.TestCase):
|
||||
def test_duckdns_has_dns_duckdns(self):
|
||||
mgr = _mgr()
|
||||
identity = {'cell_name': 'gamma', 'domain_mode': 'duckdns'}
|
||||
out = mgr.generate_caddyfile(identity, [])
|
||||
self.assertIn('dns duckdns {$DUCKDNS_TOKEN}', out)
|
||||
self.assertIn('*.gamma.duckdns.org', out)
|
||||
|
||||
|
||||
class TestGenerateCaddyfileHttp01(unittest.TestCase):
|
||||
def test_http01_no_tls_block_and_per_service_blocks(self):
|
||||
mgr = _mgr()
|
||||
identity = {
|
||||
'cell_name': 'delta',
|
||||
'domain_mode': 'http01',
|
||||
'custom_domain': 'delta.noip.me',
|
||||
}
|
||||
services = [
|
||||
{'name': 'calendar', 'caddy_route':
|
||||
'reverse_proxy cell-radicale:5232'},
|
||||
{'name': 'files', 'caddy_route':
|
||||
'reverse_proxy cell-filegator:8080'},
|
||||
]
|
||||
out = mgr.generate_caddyfile(identity, services)
|
||||
# No wildcard, no DNS-01 plugins.
|
||||
self.assertNotIn('*.delta', out)
|
||||
self.assertNotIn('dns ', out)
|
||||
# No explicit tls block (no internal CA, no plugin) — the host block
|
||||
# itself is left empty so Caddy uses HTTP-01 by default.
|
||||
self.assertNotIn('tls {', out)
|
||||
# Per-service blocks
|
||||
self.assertIn('calendar.delta.noip.me {', out)
|
||||
self.assertIn('files.delta.noip.me {', out)
|
||||
self.assertIn('reverse_proxy cell-radicale:5232', out)
|
||||
self.assertIn('reverse_proxy cell-filegator:8080', out)
|
||||
|
||||
|
||||
class TestServiceRoutesIncluded(unittest.TestCase):
|
||||
def test_installed_service_route_appears_in_output(self):
|
||||
mgr = _mgr()
|
||||
identity = {'cell_name': 'eps', 'domain_mode': 'lan'}
|
||||
services = [
|
||||
{'name': 'calendar', 'caddy_route': CALENDAR_ROUTE},
|
||||
{'name': 'files', 'caddy_route': FILES_ROUTE},
|
||||
]
|
||||
out = mgr.generate_caddyfile(identity, services)
|
||||
self.assertIn('handle /calendar*', out)
|
||||
self.assertIn('reverse_proxy cell-radicale:5232', out)
|
||||
self.assertIn('handle /files*', out)
|
||||
self.assertIn('reverse_proxy cell-filegator:8080', out)
|
||||
# Core routes still emitted
|
||||
self.assertIn('reverse_proxy cell-api:3000', out)
|
||||
self.assertIn('reverse_proxy cell-webui:80', out)
|
||||
|
||||
|
||||
class TestReloadCaddyAdminAPI(unittest.TestCase):
|
||||
def test_reload_calls_admin_api_load_endpoint(self):
|
||||
mgr = _mgr()
|
||||
# Point at a tmp Caddyfile so we can read it back during reload.
|
||||
import tempfile
|
||||
tmp = tempfile.NamedTemporaryFile('w', delete=False, suffix='.caddyfile')
|
||||
tmp.write(":80 { reverse_proxy cell-webui:80 }\n")
|
||||
tmp.close()
|
||||
mgr.caddyfile_path = tmp.name
|
||||
|
||||
with patch('caddy_manager.requests.post') as mock_post:
|
||||
mock_post.return_value = MagicMock(status_code=200, text='ok')
|
||||
ok = mgr.reload_caddy()
|
||||
|
||||
self.assertTrue(ok)
|
||||
mock_post.assert_called_once()
|
||||
args, kwargs = mock_post.call_args
|
||||
# First positional arg is the URL
|
||||
self.assertEqual(args[0], 'http://cell-caddy:2019/load')
|
||||
self.assertEqual(kwargs['headers']['Content-Type'], 'text/caddyfile')
|
||||
self.assertIn('cell-webui:80', kwargs['data'])
|
||||
os.unlink(tmp.name)
|
||||
|
||||
|
||||
class TestHealthCheck(unittest.TestCase):
|
||||
def test_returns_true_on_200(self):
|
||||
mgr = _mgr()
|
||||
with patch('caddy_manager.requests.get') as mock_get:
|
||||
mock_get.return_value = MagicMock(status_code=200)
|
||||
self.assertTrue(mgr.check_caddy_health())
|
||||
mock_get.assert_called_once()
|
||||
# URL must be the admin API root
|
||||
self.assertIn('cell-caddy:2019', mock_get.call_args[0][0])
|
||||
|
||||
def test_returns_false_on_connection_error(self):
|
||||
mgr = _mgr()
|
||||
with patch('caddy_manager.requests.get',
|
||||
side_effect=requests.ConnectionError('refused')):
|
||||
self.assertFalse(mgr.check_caddy_health())
|
||||
|
||||
def test_returns_false_on_non_200(self):
|
||||
mgr = _mgr()
|
||||
with patch('caddy_manager.requests.get') as mock_get:
|
||||
mock_get.return_value = MagicMock(status_code=500)
|
||||
self.assertFalse(mgr.check_caddy_health())
|
||||
|
||||
|
||||
class TestFailureCounter(unittest.TestCase):
|
||||
def test_increments_and_resets(self):
|
||||
mgr = _mgr()
|
||||
self.assertEqual(mgr.get_health_failure_count(), 0)
|
||||
self.assertEqual(mgr.increment_health_failure(), 1)
|
||||
self.assertEqual(mgr.increment_health_failure(), 2)
|
||||
self.assertEqual(mgr.increment_health_failure(), 3)
|
||||
self.assertEqual(mgr.get_health_failure_count(), 3)
|
||||
mgr.reset_health_failures()
|
||||
self.assertEqual(mgr.get_health_failure_count(), 0)
|
||||
|
||||
|
||||
class TestCertStatus(unittest.TestCase):
|
||||
def test_returns_default_when_no_tls_in_identity(self):
|
||||
mgr = _mgr(identity={'cell_name': 'x', 'domain_mode': 'lan'})
|
||||
out = mgr.get_cert_status()
|
||||
self.assertEqual(out['status'], 'unknown')
|
||||
self.assertIsNone(out['expiry'])
|
||||
self.assertIsNone(out['days_remaining'])
|
||||
|
||||
def test_returns_tls_block_when_present(self):
|
||||
mgr = _mgr(identity={
|
||||
'cell_name': 'x',
|
||||
'domain_mode': 'pic_ngo',
|
||||
'tls': {
|
||||
'status': 'valid',
|
||||
'expiry': '2026-08-01T00:00:00Z',
|
||||
'days_remaining': 84,
|
||||
},
|
||||
})
|
||||
out = mgr.get_cert_status()
|
||||
self.assertEqual(out['status'], 'valid')
|
||||
self.assertEqual(out['expiry'], '2026-08-01T00:00:00Z')
|
||||
self.assertEqual(out['days_remaining'], 84)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user