""" Tests for app.py: health_history (deque), health monitor logic, connectivity endpoints, caddy endpoints, egress endpoints, and before-request hooks (enforce_setup/enforce_auth/check_csrf). """ import sys from pathlib import Path import json from collections import deque from unittest.mock import patch, MagicMock import pytest sys.path.insert(0, str(Path(__file__).parent.parent / 'api')) import app as app_module from app import app @pytest.fixture(autouse=True) def reset_app_state(): """Reset global mutable state between tests.""" orig_running = app_module.health_monitor_running orig_counters = dict(app_module.service_alert_counters) app.config['TESTING'] = True yield app_module.health_monitor_running = orig_running app_module.service_alert_counters = orig_counters @pytest.fixture def client(): app.config['TESTING'] = True with app.test_client() as c: yield c # --------------------------------------------------------------------------- # health_history is a deque (not a list) # --------------------------------------------------------------------------- class TestHealthHistoryIsDeque: def test_health_history_is_deque(self): assert isinstance(app_module.health_history, deque) def test_health_history_has_maxlen(self): assert app_module.health_history.maxlen == app_module.HEALTH_HISTORY_SIZE def test_health_history_appendleft_works(self): """appendleft (used in health_monitor_loop) should work on a deque.""" hh = app_module.health_history entry = {'timestamp': '2026-01-01T00:00:00', 'alerts': []} hh.appendleft(entry) assert hh[0] == entry def test_health_history_maxlen_evicts_old_entries(self): hh = deque(maxlen=3) for i in range(5): hh.appendleft({'n': i}) assert len(hh) == 3 # Most recent is first assert hh[0]['n'] == 4 # --------------------------------------------------------------------------- # GET /api/health/history # --------------------------------------------------------------------------- class TestGetHealthHistory: def test_returns_200(self, client): with patch.object(app_module, 'health_history', deque(maxlen=100)): resp = client.get('/api/health/history') assert resp.status_code == 200 def test_returns_list(self, client): with patch.object(app_module, 'health_history', deque(maxlen=100)): resp = client.get('/api/health/history') data = json.loads(resp.data) assert isinstance(data, list) def test_returns_stored_entries(self, client): hh = deque(maxlen=100) hh.appendleft({'timestamp': 't1', 'alerts': []}) hh.appendleft({'timestamp': 't2', 'alerts': []}) with patch.object(app_module, 'health_history', hh): resp = client.get('/api/health/history') data = json.loads(resp.data) assert len(data) == 2 def test_returns_empty_when_no_history(self, client): with patch.object(app_module, 'health_history', deque(maxlen=100)): resp = client.get('/api/health/history') assert json.loads(resp.data) == [] # --------------------------------------------------------------------------- # POST /api/health/history/clear # --------------------------------------------------------------------------- class TestClearHealthHistory: def test_clear_returns_200(self, client): hh = deque(maxlen=100) hh.appendleft({'entry': 1}) with patch.object(app_module, 'health_history', hh): resp = client.post('/api/health/history/clear') assert resp.status_code == 200 def test_clear_empties_history(self, client): hh = deque(maxlen=100) hh.appendleft({'entry': 1}) with patch.object(app_module, 'health_history', hh): client.post('/api/health/history/clear') assert len(hh) == 0 def test_clear_resets_alert_counters(self, client): app_module.service_alert_counters['network'] = 5 hh = deque(maxlen=100) with patch.object(app_module, 'health_history', hh): client.post('/api/health/history/clear') assert app_module.service_alert_counters == {} def test_clear_response_has_message(self, client): hh = deque(maxlen=100) with patch.object(app_module, 'health_history', hh): resp = client.post('/api/health/history/clear') data = json.loads(resp.data) assert 'message' in data # --------------------------------------------------------------------------- # perform_health_check alerting logic # --------------------------------------------------------------------------- class TestPerformHealthCheck: def test_healthy_service_resets_counter(self): app_module.service_alert_counters['network'] = 2 mock_service_bus = MagicMock() mock_service_bus.list_services.return_value = ['network'] network_svc = MagicMock() network_svc.health_check.return_value = {'running': True} mock_service_bus.get_service.return_value = network_svc mock_cfg = MagicMock() mock_cfg.get_installed_services.return_value = [] with patch.object(app_module, 'service_bus', mock_service_bus), \ patch.object(app_module, 'config_manager', mock_cfg), \ app.app_context(): result = app_module.perform_health_check() assert app_module.service_alert_counters.get('network', 0) == 0 assert 'network' in result def test_unhealthy_service_with_error_key_increments_counter(self): """Services that raise an exception get recorded with an 'error' key, which the alerting logic recognises as unhealthy.""" app_module.service_alert_counters = {} mock_service_bus = MagicMock() mock_service_bus.list_services.return_value = ['network'] mock_service_bus.publish_event = MagicMock() network_svc = MagicMock() # Raise so the result gets {'error': ..., 'status': 'offline'} network_svc.health_check.side_effect = Exception('container down') mock_service_bus.get_service.return_value = network_svc mock_cfg = MagicMock() mock_cfg.get_installed_services.return_value = [] with patch.object(app_module, 'service_bus', mock_service_bus), \ patch.object(app_module, 'config_manager', mock_cfg), \ app.app_context(): app_module.perform_health_check() # With an 'error' key and no 'running' key, healthy=False → counter increments assert app_module.service_alert_counters.get('network', 0) == 1 def test_alert_triggered_at_threshold(self): """Counter reaching HEALTH_ALERT_THRESHOLD emits an alert.""" app_module.service_alert_counters = {'network': app_module.HEALTH_ALERT_THRESHOLD - 1} mock_service_bus = MagicMock() mock_service_bus.list_services.return_value = ['network'] mock_service_bus.publish_event = MagicMock() network_svc = MagicMock() # Use exception path to guarantee healthy=False network_svc.health_check.side_effect = Exception('container down') mock_service_bus.get_service.return_value = network_svc mock_cfg = MagicMock() mock_cfg.get_installed_services.return_value = [] with patch.object(app_module, 'service_bus', mock_service_bus), \ patch.object(app_module, 'config_manager', mock_cfg), \ app.app_context(): result = app_module.perform_health_check() # Alert should be in result['alerts'] assert len(result['alerts']) >= 1 assert any('network' in a for a in result['alerts']) def test_optional_store_services_skipped_when_not_installed(self): mock_service_bus = MagicMock() mock_service_bus.list_services.return_value = ['email_manager'] mock_cfg = MagicMock() mock_cfg.get_installed_services.return_value = [] # email not installed with patch.object(app_module, 'service_bus', mock_service_bus), \ patch.object(app_module, 'config_manager', mock_cfg), \ app.app_context(): result = app_module.perform_health_check() # email_manager should not appear in result (was skipped) assert 'email_manager' not in result def test_optional_store_service_checked_when_installed(self): mock_service_bus = MagicMock() mock_service_bus.list_services.return_value = ['email_manager'] mock_service_bus.publish_event = MagicMock() email_svc = MagicMock() email_svc.health_check.return_value = {'running': True} mock_service_bus.get_service.return_value = email_svc mock_cfg = MagicMock() mock_cfg.get_installed_services.return_value = ['email'] # email installed with patch.object(app_module, 'service_bus', mock_service_bus), \ patch.object(app_module, 'config_manager', mock_cfg), \ app.app_context(): result = app_module.perform_health_check() assert 'email_manager' in result def test_service_without_health_check_falls_back_to_get_status(self): mock_service_bus = MagicMock() mock_service_bus.list_services.return_value = ['routing'] svc = MagicMock(spec=[]) # no health_check attribute svc.get_status = MagicMock(return_value={'running': True}) mock_service_bus.get_service.return_value = svc mock_cfg = MagicMock() mock_cfg.get_installed_services.return_value = [] with patch.object(app_module, 'service_bus', mock_service_bus), \ patch.object(app_module, 'config_manager', mock_cfg), \ app.app_context(): result = app_module.perform_health_check() assert 'routing' in result def test_service_exception_recorded_as_error(self): mock_service_bus = MagicMock() mock_service_bus.list_services.return_value = ['vault'] svc = MagicMock() svc.health_check.side_effect = Exception('vault down') mock_service_bus.get_service.return_value = svc mock_cfg = MagicMock() mock_cfg.get_installed_services.return_value = [] with patch.object(app_module, 'service_bus', mock_service_bus), \ patch.object(app_module, 'config_manager', mock_cfg), \ app.app_context(): result = app_module.perform_health_check() assert 'error' in result.get('vault', {}) # --------------------------------------------------------------------------- # GET /api/connectivity/status # --------------------------------------------------------------------------- class TestConnectivityEndpoints: def test_connectivity_status_200(self, client): mock_cm = MagicMock() mock_cm.get_status.return_value = {'exits': [], 'peers': {}} with patch.object(app_module, 'connectivity_manager', mock_cm): resp = client.get('/api/connectivity/status') assert resp.status_code == 200 def test_connectivity_status_shape(self, client): mock_cm = MagicMock() mock_cm.get_status.return_value = {'exits': [], 'peers': {}} with patch.object(app_module, 'connectivity_manager', mock_cm): resp = client.get('/api/connectivity/status') data = json.loads(resp.data) assert 'exits' in data def test_connectivity_status_500_on_exception(self, client): mock_cm = MagicMock() mock_cm.get_status.side_effect = Exception('fail') with patch.object(app_module, 'connectivity_manager', mock_cm): resp = client.get('/api/connectivity/status') assert resp.status_code == 500 def test_connectivity_list_exits_200(self, client): mock_cm = MagicMock() mock_cm.list_exits.return_value = [] with patch.object(app_module, 'connectivity_manager', mock_cm): resp = client.get('/api/connectivity/exits') assert resp.status_code == 200 def test_connectivity_list_exits_shape(self, client): mock_cm = MagicMock() mock_cm.list_exits.return_value = [{'type': 'wireguard_ext', 'name': 'exit1'}] with patch.object(app_module, 'connectivity_manager', mock_cm): resp = client.get('/api/connectivity/exits') data = json.loads(resp.data) assert 'exits' in data assert len(data['exits']) == 1 def test_connectivity_upload_wireguard_missing_conf_text(self, client): resp = client.post('/api/connectivity/exits/wireguard', data=json.dumps({}), content_type='application/json') assert resp.status_code == 400 data = json.loads(resp.data) assert 'error' in data def test_connectivity_upload_wireguard_empty_conf_text(self, client): resp = client.post('/api/connectivity/exits/wireguard', data=json.dumps({'conf_text': ' '}), content_type='application/json') assert resp.status_code == 400 def test_connectivity_upload_wireguard_success(self, client): mock_cm = MagicMock() mock_cm.upload_wireguard_ext.return_value = {'ok': True, 'message': 'Uploaded'} with patch.object(app_module, 'connectivity_manager', mock_cm): resp = client.post('/api/connectivity/exits/wireguard', data=json.dumps({'conf_text': '[Interface]\nPrivateKey = abc\n'}), content_type='application/json') assert resp.status_code == 200 def test_connectivity_upload_wireguard_failure(self, client): mock_cm = MagicMock() mock_cm.upload_wireguard_ext.return_value = {'ok': False, 'error': 'bad config'} with patch.object(app_module, 'connectivity_manager', mock_cm): resp = client.post('/api/connectivity/exits/wireguard', data=json.dumps({'conf_text': '[Interface]\nPrivateKey = abc\n'}), content_type='application/json') assert resp.status_code == 400 def test_connectivity_upload_openvpn_missing_ovpn_text(self, client): resp = client.post('/api/connectivity/exits/openvpn', data=json.dumps({}), content_type='application/json') assert resp.status_code == 400 def test_connectivity_upload_openvpn_success(self, client): mock_cm = MagicMock() mock_cm.upload_openvpn.return_value = {'ok': True} with patch.object(app_module, 'connectivity_manager', mock_cm): resp = client.post('/api/connectivity/exits/openvpn', data=json.dumps({'ovpn_text': 'client\ndev tun\n'}), content_type='application/json') assert resp.status_code == 200 def test_connectivity_apply_routes_200(self, client): mock_cm = MagicMock() mock_cm.apply_routes.return_value = {'ok': True, 'applied': 0} with patch.object(app_module, 'connectivity_manager', mock_cm): resp = client.post('/api/connectivity/exits/apply', content_type='application/json') assert resp.status_code == 200 def test_connectivity_set_peer_exit_missing_exit_via(self, client): resp = client.put('/api/connectivity/peers/alice/exit', data=json.dumps({}), content_type='application/json') assert resp.status_code == 400 def test_connectivity_set_peer_exit_success(self, client): mock_cm = MagicMock() mock_cm.set_peer_exit.return_value = {'ok': True} with patch.object(app_module, 'connectivity_manager', mock_cm): resp = client.put('/api/connectivity/peers/alice/exit', data=json.dumps({'exit_via': 'wireguard_ext'}), content_type='application/json') assert resp.status_code == 200 def test_connectivity_set_peer_exit_failure(self, client): mock_cm = MagicMock() mock_cm.set_peer_exit.return_value = {'ok': False, 'error': 'not found'} with patch.object(app_module, 'connectivity_manager', mock_cm): resp = client.put('/api/connectivity/peers/alice/exit', data=json.dumps({'exit_via': 'wireguard_ext'}), content_type='application/json') assert resp.status_code == 400 def test_connectivity_get_peer_exits_200(self, client): mock_cm = MagicMock() mock_cm.get_peer_exits.return_value = {'alice': 'wireguard_ext'} with patch.object(app_module, 'connectivity_manager', mock_cm): resp = client.get('/api/connectivity/peers') assert resp.status_code == 200 data = json.loads(resp.data) assert 'peers' in data # --------------------------------------------------------------------------- # GET /api/caddy/cert-status and POST /api/caddy/cert-renew # --------------------------------------------------------------------------- class TestCaddyEndpoints: def test_caddy_cert_status_200(self, client): mock_caddy = MagicMock() mock_caddy.get_cert_status_fresh.return_value = {'status': 'valid', 'days_remaining': 60} with patch.object(app_module, 'caddy_manager', mock_caddy): resp = client.get('/api/caddy/cert-status') assert resp.status_code == 200 def test_caddy_cert_status_shape(self, client): mock_caddy = MagicMock() mock_caddy.get_cert_status_fresh.return_value = {'status': 'internal'} with patch.object(app_module, 'caddy_manager', mock_caddy): resp = client.get('/api/caddy/cert-status') data = json.loads(resp.data) assert 'status' in data def test_caddy_cert_status_500_on_exception(self, client): mock_caddy = MagicMock() mock_caddy.get_cert_status_fresh.side_effect = Exception('Caddy unreachable') with patch.object(app_module, 'caddy_manager', mock_caddy): resp = client.get('/api/caddy/cert-status') assert resp.status_code == 500 def test_caddy_cert_renew_success(self, client): mock_caddy = MagicMock() mock_caddy.renew_cert.return_value = {'ok': True, 'status': 'pending'} with patch.object(app_module, 'caddy_manager', mock_caddy): resp = client.post('/api/caddy/cert-renew', content_type='application/json') assert resp.status_code == 200 def test_caddy_cert_renew_failure(self, client): mock_caddy = MagicMock() mock_caddy.renew_cert.return_value = {'ok': False, 'error': 'LAN mode'} with patch.object(app_module, 'caddy_manager', mock_caddy): resp = client.post('/api/caddy/cert-renew', content_type='application/json') assert resp.status_code == 400 def test_caddy_cert_renew_500_on_exception(self, client): mock_caddy = MagicMock() mock_caddy.renew_cert.side_effect = Exception('fail') with patch.object(app_module, 'caddy_manager', mock_caddy): resp = client.post('/api/caddy/cert-renew', content_type='application/json') assert resp.status_code == 500 def test_caddy_upload_custom_cert_missing_fields(self, client): resp = client.post('/api/caddy/custom-cert', data=json.dumps({}), content_type='application/json') assert resp.status_code == 400 def test_caddy_upload_custom_cert_success(self, client): mock_caddy = MagicMock() mock_caddy.upload_custom_cert.return_value = {'ok': True} with patch.object(app_module, 'caddy_manager', mock_caddy): resp = client.post('/api/caddy/custom-cert', data=json.dumps({'cert_pem': 'CERT', 'key_pem': 'KEY'}), content_type='application/json') assert resp.status_code == 200 def test_caddy_upload_custom_cert_failure(self, client): mock_caddy = MagicMock() mock_caddy.upload_custom_cert.return_value = {'ok': False, 'error': 'invalid cert'} with patch.object(app_module, 'caddy_manager', mock_caddy): resp = client.post('/api/caddy/custom-cert', data=json.dumps({'cert_pem': 'BAD', 'key_pem': 'BAD'}), content_type='application/json') assert resp.status_code == 422 # --------------------------------------------------------------------------- # GET /api/egress/status and PUT /api/egress/services//exit # --------------------------------------------------------------------------- class TestEgressEndpoints: def test_egress_status_200(self, client): mock_egress = MagicMock() mock_egress.get_status.return_value = {'services': {}} with patch('app.egress_manager', mock_egress, create=True): resp = client.get('/api/egress/status') assert resp.status_code == 200 def test_egress_status_500_on_exception(self, client): mock_egress = MagicMock() mock_egress.get_status.side_effect = Exception('fail') with patch('app.egress_manager', mock_egress, create=True): resp = client.get('/api/egress/status') assert resp.status_code == 500 def test_egress_set_service_exit_missing_exit_type(self, client): mock_egress = MagicMock() with patch('app.egress_manager', mock_egress, create=True): resp = client.put('/api/egress/services/email/exit', data=json.dumps({}), content_type='application/json') assert resp.status_code == 400 def test_egress_set_service_exit_success(self, client): mock_egress = MagicMock() mock_egress.set_service_exit.return_value = {'ok': True} with patch('app.egress_manager', mock_egress, create=True): resp = client.put('/api/egress/services/email/exit', data=json.dumps({'exit_type': 'wireguard_ext'}), content_type='application/json') assert resp.status_code == 200 def test_egress_set_service_exit_failure(self, client): mock_egress = MagicMock() mock_egress.set_service_exit.return_value = {'ok': False, 'error': 'not found'} with patch('app.egress_manager', mock_egress, create=True): resp = client.put('/api/egress/services/email/exit', data=json.dumps({'exit_type': 'wireguard_ext'}), content_type='application/json') assert resp.status_code == 400 # --------------------------------------------------------------------------- # enforce_setup hook: returns 428 when setup is not complete # --------------------------------------------------------------------------- class TestEnforceSetupHook: def test_428_when_setup_incomplete(self): """Without TESTING=True, API requests are blocked if setup is not done.""" app.config['TESTING'] = False mock_setup = MagicMock() mock_setup.is_setup_complete.return_value = False try: with patch.object(app_module, 'setup_manager', mock_setup): with app.test_client() as c: resp = c.get('/api/status') assert resp.status_code == 428 data = json.loads(resp.data) assert 'redirect' in data finally: app.config['TESTING'] = True def test_setup_route_passes_when_incomplete(self): """Setup routes always pass through regardless of setup status.""" app.config['TESTING'] = False mock_setup = MagicMock() mock_setup.is_setup_complete.return_value = False try: with patch.object(app_module, 'setup_manager', mock_setup): with app.test_client() as c: resp = c.get('/api/setup/status') # Should NOT be 428 assert resp.status_code != 428 finally: app.config['TESTING'] = True def test_health_passes_when_incomplete(self): """The /health endpoint always passes through.""" app.config['TESTING'] = False mock_setup = MagicMock() mock_setup.is_setup_complete.return_value = False try: with patch.object(app_module, 'setup_manager', mock_setup): with app.test_client() as c: resp = c.get('/health') assert resp.status_code == 200 finally: app.config['TESTING'] = True def test_setup_complete_passes_through(self): """All routes pass through when setup is complete.""" app.config['TESTING'] = False mock_setup = MagicMock() mock_setup.is_setup_complete.return_value = True mock_auth = MagicMock() mock_auth.list_users.return_value = [] try: with patch.object(app_module, 'setup_manager', mock_setup), \ patch.object(app_module, 'auth_manager', mock_auth): with app.test_client() as c: resp = c.get('/api/status') assert resp.status_code != 428 finally: app.config['TESTING'] = True # --------------------------------------------------------------------------- # enforce_auth hook: 503 when users file exists but is empty # --------------------------------------------------------------------------- class TestEnforceAuthHook: def test_503_when_users_file_empty_and_readable(self, tmp_path): """Returns 503 when users file exists + readable but has no accounts.""" import tempfile, os app.config['TESTING'] = False users_file = tmp_path / 'auth_users.json' users_file.write_text('[]') # file exists but no accounts from auth_manager import AuthManager real_auth = MagicMock(spec=AuthManager) real_auth.list_users.return_value = [] real_auth._users_file = str(users_file) mock_setup = MagicMock() mock_setup.is_setup_complete.return_value = True try: with patch.object(app_module, 'auth_manager', real_auth), \ patch.object(app_module, 'setup_manager', mock_setup): with app.test_client() as c: resp = c.get('/api/status') assert resp.status_code == 503 data = json.loads(resp.data) assert 'error' in data finally: app.config['TESTING'] = True def test_401_when_no_session_and_users_exist(self, tmp_path): """Returns 401 when users exist but no session cookie is set.""" app.config['TESTING'] = False users_file = tmp_path / 'auth_users.json' # Users file doesn't exist — no file means enforcement # is bypassed. Use a file that DOES have a user. import json as _json users_file.write_text(_json.dumps([{'username': 'admin', 'role': 'admin'}])) from auth_manager import AuthManager real_auth = MagicMock(spec=AuthManager) real_auth.list_users.return_value = [{'username': 'admin', 'role': 'admin'}] real_auth._users_file = str(users_file) mock_setup = MagicMock() mock_setup.is_setup_complete.return_value = True try: with patch.object(app_module, 'auth_manager', real_auth), \ patch.object(app_module, 'setup_manager', mock_setup): with app.test_client() as c: # No login — no session resp = c.get('/api/status') assert resp.status_code == 401 finally: app.config['TESTING'] = True # --------------------------------------------------------------------------- # GET /api/status # --------------------------------------------------------------------------- class TestGetCellStatus: def test_returns_200(self, client): mock_sb = MagicMock() mock_sb.list_services.return_value = [] mock_pr = MagicMock() mock_pr.list_peers.return_value = [] mock_cm = MagicMock() mock_cm.configs = {'_identity': {'cell_name': 'test', 'domain': 'cell'}} mock_cm.get_effective_domain.return_value = 'cell' with patch.object(app_module, 'service_bus', mock_sb), \ patch.object(app_module, 'peer_registry', mock_pr), \ patch.object(app_module, 'config_manager', mock_cm): resp = client.get('/api/status') assert resp.status_code == 200 def test_status_includes_expected_keys(self, client): mock_sb = MagicMock() mock_sb.list_services.return_value = [] mock_pr = MagicMock() mock_pr.list_peers.return_value = [] mock_cm = MagicMock() mock_cm.configs = {'_identity': {'cell_name': 'test', 'domain': 'cell'}} mock_cm.get_effective_domain.return_value = 'cell' with patch.object(app_module, 'service_bus', mock_sb), \ patch.object(app_module, 'peer_registry', mock_pr), \ patch.object(app_module, 'config_manager', mock_cm): resp = client.get('/api/status') data = json.loads(resp.data) for key in ('cell_name', 'domain', 'uptime', 'peers_count', 'services'): assert key in data, f"Missing key: {key}"