import sys from pathlib import Path # Add api directory to path api_dir = Path(__file__).parent.parent / 'api' sys.path.insert(0, str(api_dir)) import unittest from unittest.mock import patch, MagicMock import threading import time import os import sys import types import builtins # Patch LOG_LEVEL and LOG_FILE in environment before importing app_module # os.environ['LOG_LEVEL'] = 'INFO' # os.environ['LOG_FILE'] = 'test.log' # Patch manager classes in builtins before importing api.app manager_names = [ 'NetworkManager', 'WireGuardManager', 'PeerRegistry', 'EmailManager', 'CalendarManager', 'FileManager', 'RoutingManager', 'CellManager', 'VaultManager', 'ContainerManager' ] for name in manager_names: setattr(builtins, name, MagicMock) builtins.LOG_LEVEL = 'INFO' # type: ignore[attr-defined] builtins.LOG_FILE = 'test.log' # type: ignore[attr-defined] sys.path.append(os.path.join(os.path.dirname(__file__), '../api')) import app as app_module # LOG_LEVEL = 'INFO' # LOG_FILE = 'test.log' class TestAppMisc(unittest.TestCase): def setUp(self): # Patch managers to avoid side effects self.patches = [ patch.object(app_module, 'network_manager', MagicMock()), patch.object(app_module, 'wireguard_manager', MagicMock()), patch.object(app_module, 'peer_registry', MagicMock()), patch.object(app_module, 'email_manager', MagicMock()), patch.object(app_module, 'calendar_manager', MagicMock()), patch.object(app_module, 'file_manager', MagicMock()), patch.object(app_module, 'routing_manager', MagicMock()), patch.object(app_module, 'container_manager', MagicMock()), ] for p in self.patches: p.start() # Patch vault_manager on app (setattr to avoid linter error) self._original_vault_manager = getattr(app_module.app, 'vault_manager', None) # type: ignore[attr-defined] setattr(app_module.app, 'vault_manager', MagicMock()) # type: ignore[attr-defined] def tearDown(self): for p in self.patches: p.stop() # Remove or restore vault_manager if self._original_vault_manager is not None: setattr(app_module.app, 'vault_manager', self._original_vault_manager) # type: ignore[attr-defined] else: delattr(app_module.app, 'vault_manager') # type: ignore[attr-defined] def test_health_monitor_thread_runs(self): # Patch health_history and service_alert_counters with patch.object(app_module, 'health_history', new=[]), \ patch.object(app_module, 'service_alert_counters', new={}), \ app_module.app.app_context(): # Patch managers to return healthy app_module.network_manager.get_status.return_value = {'ok': True} app_module.wireguard_manager.get_status.return_value = {'ok': True} app_module.email_manager.get_status.return_value = {'ok': True} app_module.calendar_manager.get_status.return_value = {'ok': True} app_module.file_manager.get_status.return_value = {'ok': True} app_module.routing_manager.get_status.return_value = {'ok': True} app_module.app.vault_manager.get_status.return_value = {'ok': True} # Run one health check result = app_module.perform_health_check() self.assertIn('network', result) self.assertIn('alerts', result) def test_enrich_log_context_sets_context(self): # Simulate Flask request context class DummyRequest: remote_addr = '127.0.0.1' method = 'GET' path = '/test' headers = {} user = type('User', (), {'id': 'user1'})() with patch('app.request', new=DummyRequest()): app_module.enrich_log_context() ctx = app_module.request_context.get() self.assertEqual(ctx['client_ip'], '127.0.0.1') self.assertEqual(ctx['method'], 'GET') self.assertEqual(ctx['path'], '/test') self.assertEqual(ctx['user'], 'user1') def _req(self, remote_addr, xff=''): class R: pass r = R() r.remote_addr = remote_addr r.headers = {'X-Forwarded-For': xff} if xff else {} return r def test_is_local_request_loopback(self): with patch('app.request', new=self._req('127.0.0.1')): self.assertTrue(app_module.is_local_request()) def test_is_local_request_public_ip(self): with patch('app.request', new=self._req('8.8.8.8')): self.assertFalse(app_module.is_local_request()) def test_is_local_request_private_ip(self): # 192.168.x.x (LAN) is no longer trusted — only Docker bridge (172.16.0.0/12) # and loopback are trusted. The API is bound to 127.0.0.1:3000 and only # reachable via Caddy (172.20.x.x), so LAN IPs never reach it directly. with patch('app.request', new=self._req('192.168.1.5')): self.assertFalse(app_module.is_local_request()) def test_is_local_request_xff_spoof_rejected(self): # Client sends X-Forwarded-For: 127.0.0.1 but actual IP is public # Old code would trust the first XFF entry — fixed to trust only last with patch('app.request', new=self._req('8.8.8.8', xff='127.0.0.1, 8.8.8.8')): self.assertFalse(app_module.is_local_request()) def test_is_local_request_xff_last_entry_local(self): # 192.168.x.x is no longer in the trusted range — only Docker bridge # (172.16.0.0/12) and loopback are trusted now. with patch('app.request', new=self._req('8.8.8.8', xff='8.8.8.8, 192.168.1.10')): self.assertFalse(app_module.is_local_request()) def test_is_local_request_xff_docker_bridge(self): # Docker bridge IPs (172.16.0.0/12) ARE trusted — Caddy uses this range with patch('app.request', new=self._req('8.8.8.8', xff='8.8.8.8, 172.20.0.2')): self.assertTrue(app_module.is_local_request()) def test_is_local_request_xff_single_public_rejected(self): with patch('app.request', new=self._req('8.8.8.8', xff='1.2.3.4')): self.assertFalse(app_module.is_local_request()) def test_is_local_request_cell_network_ip(self): # 172.20.0.10 is the API container's IP — should be allowed with patch('app.request', new=self._req('172.20.0.10')): self.assertTrue(app_module.is_local_request()) def test_health_check_exception(self): # Patch datetime to raise exception with patch('app.datetime') as mock_dt, app_module.app.app_context(): mock_dt.utcnow.side_effect = Exception('fail') client = app_module.app.test_client() response = client.get('/health') self.assertIn(response.status_code, (200, 500)) data = response.get_json(silent=True) # Accept either a valid JSON with 'error' or None if data is not None and response.status_code == 500: self.assertIn('error', data) def test_get_cell_status_exception(self): with app_module.app.app_context(): app_module.network_manager.get_status.side_effect = Exception('fail') client = app_module.app.test_client() response = client.get('/api/status') # The route handles per-service exceptions internally and returns 200 # with per-service error info; only outer failures yield 500 self.assertIn(response.status_code, (200, 500)) data = response.get_json(silent=True) self.assertIsNotNone(data) def test_get_config_exception(self): with patch('app.datetime') as mock_dt, app_module.app.app_context(): mock_dt.utcnow.side_effect = Exception('fail') client = app_module.app.test_client() response = client.get('/api/config') self.assertIn(response.status_code, (200, 500)) data = response.get_json(silent=True) # Accept either a valid config dict or an error if data is not None and response.status_code == 500: self.assertIn('error', data) if __name__ == '__main__': unittest.main()