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