feat: HTTPS cert status, IDENTITY_CHANGED wiring, remove stale ip_utils Caddyfile writes
Unit Tests / test (push) Successful in 11m18s

- CaddyManager: add refresh_cert_status() and get_cert_status_fresh() that
  open a live TLS connection to cell-caddy:443 to read cert expiry; avoids
  needing a volume mount into the API container
- CaddyManager: periodic cert refresh in health_monitor_loop (every 60 cycles)
- config.py PUT /api/ddns: publish IDENTITY_CHANGED so CaddyManager regenerates
  the Caddyfile immediately after any domain/cell_name change — previously the
  event was never fired from this route
- config.py: remove all ip_utils.write_caddyfile() calls; CaddyManager is now
  the sole authority for Caddyfile generation
- app.py: add GET /api/caddy/cert-status route
- app.py: add GET /api/egress/status and PUT /api/egress/services/<id>/exit routes
- Settings.jsx: display cert status badge (valid/expired/internal/unknown) with
  expiry date and days-remaining in the domain section
- Tests: TestRefreshCertStatus (8 tests), TestDdnsConfigUpdatesFiresIdentityChanged,
  TestCaddyCertStatusRoute added; fix expired-cert helper to set not_valid_before
  relative to expiry so it's always earlier

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 11:39:36 -04:00
parent 41d09c598b
commit 0267dce73d
7 changed files with 398 additions and 26 deletions
+115
View File
@@ -190,5 +190,120 @@ class TestConfigApplyRoute(unittest.TestCase):
self.assertIn('error', json.loads(r.data))
class TestDdnsConfigUpdatesFiresIdentityChanged(unittest.TestCase):
"""PUT /api/ddns must publish IDENTITY_CHANGED so CaddyManager regenerates."""
def setUp(self):
app.config['TESTING'] = True
self.client = app.test_client()
def _put_ddns(self, payload=None):
if payload is None:
payload = {'domain_mode': 'pic_ngo', 'cell_name': 'test', 'domain': 'pic_ngo'}
return self.client.put(
'/api/ddns',
data=json.dumps(payload),
content_type='application/json',
)
@patch('app.service_bus')
@patch('app.config_manager')
def test_fires_identity_changed_on_success(self, mock_cm, mock_bus):
mock_cm.configs = {
'_identity': {
'cell_name': 'test',
'domain': 'pic_ngo',
'domain_name': '',
'domain_mode': 'pic_ngo',
}
}
mock_cm.set_identity_field = MagicMock()
mock_cm.get_effective_domain = MagicMock(return_value='test.pic.ngo')
mock_cm.validate_ddns_config = MagicMock(return_value=None)
r = self._put_ddns()
self.assertIn(r.status_code, (200, 204))
self.assertTrue(mock_bus.publish_event.called,
'Expected service_bus.publish_event to be called')
args = mock_bus.publish_event.call_args
# first positional arg should be an EventType with value IDENTITY_CHANGED
event_arg = args[0][0]
self.assertEqual(str(event_arg).upper().replace('.', '_'),
'EVENTTYPE_IDENTITY_CHANGED')
@patch('app.service_bus')
@patch('app.config_manager')
def test_identity_changed_payload_contains_domain_fields(self, mock_cm, mock_bus):
mock_cm.configs = {
'_identity': {
'cell_name': 'mycell',
'domain': 'pic_ngo',
'domain_name': '',
'domain_mode': 'pic_ngo',
}
}
mock_cm.set_identity_field = MagicMock()
mock_cm.get_effective_domain = MagicMock(return_value='mycell.pic.ngo')
mock_cm.validate_ddns_config = MagicMock(return_value=None)
self._put_ddns({'domain_mode': 'pic_ngo', 'cell_name': 'mycell', 'domain': 'pic_ngo'})
if mock_bus.publish_event.called:
kwargs = mock_bus.publish_event.call_args[1] if mock_bus.publish_event.call_args[1] else {}
pos_args = mock_bus.publish_event.call_args[0]
# payload is 3rd positional arg
if len(pos_args) >= 3:
payload = pos_args[2]
self.assertIn('cell_name', payload)
self.assertIn('effective_domain', payload)
class TestCaddyCertStatusRoute(unittest.TestCase):
"""GET /api/caddy/cert-status delegates to CaddyManager and handles errors."""
def setUp(self):
app.config['TESTING'] = True
self.client = app.test_client()
def test_returns_cert_status_200(self):
expected = {
'status': 'valid',
'expiry': '2026-12-01T00:00:00+00:00',
'days_remaining': 179,
}
mock_caddy = MagicMock()
mock_caddy.get_cert_status_fresh.return_value = expected
with patch('app.caddy_manager', mock_caddy):
r = self.client.get('/api/caddy/cert-status')
self.assertEqual(r.status_code, 200)
data = json.loads(r.data)
self.assertEqual(data['status'], 'valid')
self.assertEqual(data['days_remaining'], 179)
def test_returns_500_on_exception(self):
mock_caddy = MagicMock()
mock_caddy.get_cert_status_fresh.side_effect = RuntimeError('ssl timeout')
with patch('app.caddy_manager', mock_caddy):
r = self.client.get('/api/caddy/cert-status')
self.assertEqual(r.status_code, 500)
data = json.loads(r.data)
self.assertIn('error', data)
def test_calls_get_cert_status_fresh_with_max_age(self):
mock_caddy = MagicMock()
mock_caddy.get_cert_status_fresh.return_value = {'status': 'internal'}
with patch('app.caddy_manager', mock_caddy):
self.client.get('/api/caddy/cert-status')
mock_caddy.get_cert_status_fresh.assert_called_once()
call_kwargs = mock_caddy.get_cert_status_fresh.call_args
# max_age_seconds should be passed (positional or keyword)
all_args = list(call_kwargs[0]) + list(call_kwargs[1].values())
self.assertTrue(
any(isinstance(a, int) and a > 0 for a in all_args),
'Expected a positive max_age_seconds argument',
)
if __name__ == '__main__':
unittest.main()