#!/usr/bin/env python3 """ test_leader_election.py — Unit tests для leader_election.py Montana Protocol Тестирование 3-Mirror Leader Election системы """ import sys import os import unittest import time from unittest.mock import Mock, patch, MagicMock from collections import deque # Добавляем путь к модулю sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../бот')) from leader_election import ( BOT_CHAIN, CHECK_INTERVAL, PING_TIMEOUT, STARTUP_DELAY, AttackDetector, LeaderElection, get_leader_election ) class TestConstants(unittest.TestCase): """Тесты констант Montana Protocol""" # ═══════════════════════════════════════════════════════════════════════ # КОНСТАНТЫ # ═══════════════════════════════════════════════════════════════════════ def test_bot_chain_structure(self): """Цепочка BOT_CHAIN имеет правильную структуру""" self.assertIsInstance(BOT_CHAIN, list) self.assertEqual(len(BOT_CHAIN), 5) # 5 узлов for node in BOT_CHAIN: self.assertIsInstance(node, tuple) self.assertEqual(len(node), 2) # (name, ip) name, ip = node self.assertIsInstance(name, str) self.assertIsInstance(ip, str) def test_bot_chain_order(self): """Проверка правильного порядка узлов в цепочке""" expected_order = [ "amsterdam", "moscow", "almaty", "spb", "novosibirsk" ] for i, (name, ip) in enumerate(BOT_CHAIN): self.assertEqual(name, expected_order[i]) def test_bot_chain_primary(self): """PRIMARY узел — Amsterdam (первый в цепочке)""" primary = BOT_CHAIN[0] self.assertEqual(primary[0], "amsterdam") self.assertEqual(primary[1], "72.56.102.240") def test_constants_values(self): """Проверка значений констант мониторинга""" self.assertEqual(CHECK_INTERVAL, 5) # 5 секунд self.assertEqual(PING_TIMEOUT, 2) # 2 секунды self.assertEqual(STARTUP_DELAY, 3) # 3 секунды class TestAttackDetector(unittest.TestCase): """Тесты AttackDetector""" def setUp(self): """Создаём новый detector перед каждым тестом""" self.detector = AttackDetector() # ═══════════════════════════════════════════════════════════════════════ # INITIALIZATION # ═══════════════════════════════════════════════════════════════════════ def test_init_default_values(self): """Инициализация с правильными дефолтными значениями""" self.assertEqual(self.detector.failure_count, 0) self.assertEqual(self.detector.max_failures, 10) self.assertEqual(self.detector.cpu_threshold, 80.0) self.assertEqual(self.detector.response_time_threshold, 5.0) self.assertFalse(self.detector.under_attack) self.assertIsInstance(self.detector.response_times, deque) self.assertEqual(self.detector.response_times.maxlen, 10) # ═══════════════════════════════════════════════════════════════════════ # FAILURE RECORDING # ═══════════════════════════════════════════════════════════════════════ def test_record_failure_increments(self): """record_failure() увеличивает счётчик""" self.assertEqual(self.detector.failure_count, 0) self.detector.record_failure() self.assertEqual(self.detector.failure_count, 1) self.detector.record_failure() self.assertEqual(self.detector.failure_count, 2) def test_record_failure_triggers_attack(self): """10 failures → атака обнаружена""" for i in range(9): self.detector.record_failure() self.assertFalse(self.detector.under_attack) # 10-я ошибка триггерит атаку self.detector.record_failure() self.assertTrue(self.detector.under_attack) self.assertEqual(self.detector.failure_count, 10) def test_record_success_decreases(self): """record_success() уменьшает счётчик failures""" self.detector.failure_count = 5 self.detector.record_success() self.assertEqual(self.detector.failure_count, 4) self.detector.record_success() self.assertEqual(self.detector.failure_count, 3) def test_record_success_not_below_zero(self): """record_success() не уходит в отрицательные""" self.detector.failure_count = 0 self.detector.record_success() self.assertEqual(self.detector.failure_count, 0) self.detector.record_success() self.assertEqual(self.detector.failure_count, 0) # ═══════════════════════════════════════════════════════════════════════ # RESPONSE TIME TRACKING # ═══════════════════════════════════════════════════════════════════════ def test_record_response_time(self): """Запись времени отклика""" self.detector.record_response_time(1.0) self.assertEqual(len(self.detector.response_times), 1) self.assertEqual(self.detector.response_times[0], 1.0) self.detector.record_response_time(2.0) self.assertEqual(len(self.detector.response_times), 2) def test_response_time_maxlen(self): """Очередь response_times ограничена 10 элементами""" for i in range(15): self.detector.record_response_time(float(i)) # Должно быть только последние 10 self.assertEqual(len(self.detector.response_times), 10) self.assertEqual(list(self.detector.response_times), list(range(5, 15, 1.0))) def test_slow_response_triggers_attack(self): """Медленный отклик (> 5 сек в среднем) → атака""" # 5 медленных ответов for _ in range(5): self.detector.record_response_time(6.0) # > 5 сек порог # Должна быть обнаружена атака self.assertTrue(self.detector.under_attack) def test_fast_response_no_attack(self): """Быстрый отклик не триггерит атаку""" for _ in range(10): self.detector.record_response_time(0.5) # < 5 сек self.assertFalse(self.detector.under_attack) # ═══════════════════════════════════════════════════════════════════════ # CPU MONITORING # ═══════════════════════════════════════════════════════════════════════ @patch('leader_election.psutil.cpu_percent') def test_check_cpu_normal(self, mock_cpu): """Нормальный CPU usage (< 80%)""" mock_cpu.return_value = 50.0 result = self.detector.check_cpu_usage() self.assertFalse(result) self.assertFalse(self.detector.under_attack) @patch('leader_election.psutil.cpu_percent') def test_check_cpu_high(self, mock_cpu): """Высокий CPU usage (> 80%) → атака""" mock_cpu.return_value = 90.0 result = self.detector.check_cpu_usage() self.assertTrue(result) self.assertTrue(self.detector.under_attack) @patch('leader_election.psutil.cpu_percent') def test_check_cpu_exactly_threshold(self, mock_cpu): """CPU ровно на пороге (80%)""" mock_cpu.return_value = 80.0 result = self.detector.check_cpu_usage() self.assertFalse(result) # 80% не > 80% @patch('leader_election.psutil.cpu_percent') def test_check_cpu_error_handling(self, mock_cpu): """Ошибка при проверке CPU не ломает систему""" mock_cpu.side_effect = Exception("CPU check failed") result = self.detector.check_cpu_usage() self.assertFalse(result) self.assertFalse(self.detector.under_attack) # ═══════════════════════════════════════════════════════════════════════ # NETWORK MONITORING # ═══════════════════════════════════════════════════════════════════════ @patch('leader_election.psutil.net_io_counters') @patch('leader_election.time.time') def test_check_network_normal(self, mock_time, mock_net_io): """Нормальный network traffic""" mock_time.return_value = 100.0 self.detector.last_check_time = 99.0 # 1 секунда назад # 10 MB за секунду (нормально) mock_net_io.return_value = Mock(bytes_recv=10 * 1024 * 1024) result = self.detector.check_network_traffic() self.assertFalse(result) self.assertFalse(self.detector.under_attack) @patch('leader_election.psutil.net_io_counters') @patch('leader_election.time.time') def test_check_network_high(self, mock_time, mock_net_io): """Высокий network traffic (> 100 MB/s) → атака""" mock_time.return_value = 100.0 self.detector.last_check_time = 99.0 # 1 секунда назад # 150 MB за секунду (атака) mock_net_io.return_value = Mock(bytes_recv=150 * 1024 * 1024) result = self.detector.check_network_traffic() self.assertTrue(result) self.assertTrue(self.detector.under_attack) # ═══════════════════════════════════════════════════════════════════════ # IS UNDER ATTACK # ═══════════════════════════════════════════════════════════════════════ @patch('leader_election.psutil.cpu_percent') @patch('leader_election.psutil.net_io_counters') def test_is_under_attack_false(self, mock_net, mock_cpu): """Нормальные метрики → не под атакой""" mock_cpu.return_value = 50.0 mock_net.return_value = Mock(bytes_recv=10 * 1024 * 1024) result = self.detector.is_under_attack() self.assertFalse(result) @patch('leader_election.psutil.cpu_percent') @patch('leader_election.psutil.net_io_counters') def test_is_under_attack_cpu_high(self, mock_net, mock_cpu): """Высокий CPU → под атакой""" mock_cpu.return_value = 95.0 mock_net.return_value = Mock(bytes_recv=10 * 1024 * 1024) result = self.detector.is_under_attack() self.assertTrue(result) # ═══════════════════════════════════════════════════════════════════════ # RESET # ═══════════════════════════════════════════════════════════════════════ def test_reset_clears_attack(self): """reset() сбрасывает флаг атаки""" # Симулируем атаку self.detector.under_attack = True self.detector.failure_count = 10 self.detector.response_times.extend([6.0, 7.0, 8.0]) self.detector.reset() self.assertFalse(self.detector.under_attack) self.assertEqual(self.detector.failure_count, 0) self.assertEqual(len(self.detector.response_times), 0) def test_reset_idempotent(self): """Множественный reset() безопасен""" self.detector.reset() self.detector.reset() self.detector.reset() self.assertFalse(self.detector.under_attack) self.assertEqual(self.detector.failure_count, 0) class TestLeaderElectionBasic(unittest.TestCase): """Базовые тесты LeaderElection""" # ═══════════════════════════════════════════════════════════════════════ # INITIALIZATION # ═══════════════════════════════════════════════════════════════════════ def test_init_default_chain(self): """Инициализация с дефолтной цепочкой""" le = LeaderElection() self.assertEqual(le.chain, BOT_CHAIN) self.assertEqual(len(le.chain), 5) def test_init_custom_chain(self): """Инициализация с кастомной цепочкой""" custom_chain = [ ("node1", "1.1.1.1"), ("node2", "2.2.2.2") ] le = LeaderElection(chain=custom_chain) self.assertEqual(le.chain, custom_chain) self.assertEqual(len(le.chain), 2) def test_init_creates_attack_detector(self): """Инициализация создаёт AttackDetector""" le = LeaderElection() self.assertIsNotNone(le.attack_detector) self.assertIsInstance(le.attack_detector, AttackDetector) def test_init_saves_original_chain(self): """Инициализация сохраняет оригинальную цепочку""" custom_chain = [("node1", "1.1.1.1"), ("node2", "2.2.2.2")] le = LeaderElection(chain=custom_chain) self.assertEqual(le.original_chain, custom_chain) self.assertIsNot(le.original_chain, le.chain) # Копия # ═══════════════════════════════════════════════════════════════════════ # SELF DETECTION # ═══════════════════════════════════════════════════════════════════════ @patch.dict(os.environ, {'MONTANA_NODE_NAME': 'moscow'}) def test_detect_self_by_node_name(self): """Определение себя по NODE_NAME""" le = LeaderElection() self.assertEqual(le.my_name, "moscow") self.assertEqual(le.my_ip, "176.124.208.93") self.assertEqual(le.my_position, 1) @patch.dict(os.environ, {'MONTANA_NODE_NAME': 'amsterdam'}) def test_detect_self_primary_node(self): """Определение PRIMARY узла""" le = LeaderElection() self.assertEqual(le.my_name, "amsterdam") self.assertEqual(le.my_position, 0) @patch.dict(os.environ, {'MONTANA_NODE_NAME': 'novosibirsk'}) def test_detect_self_last_node(self): """Определение последнего узла в цепочке""" le = LeaderElection() self.assertEqual(le.my_name, "novosibirsk") self.assertEqual(le.my_position, 4) # Последний в 5-узловой цепочке @patch.dict(os.environ, {}, clear=True) def test_detect_self_fallback(self): """Fallback на первый узел если не определён""" le = LeaderElection() # Должен выбрать первый узел как fallback self.assertEqual(le.my_name, "amsterdam") self.assertEqual(le.my_position, 0) # ═══════════════════════════════════════════════════════════════════════ # CHAIN STATUS # ═══════════════════════════════════════════════════════════════════════ @patch('leader_election.check_node_health') def test_get_chain_status_all_alive(self, mock_health): """Статус цепочки: все живы""" mock_health.return_value = True le = LeaderElection() status = le.get_chain_status() # Должны быть зелёные кружки для всех узлов self.assertIn("🟢", status) self.assertNotIn("🔴", status) @patch('leader_election.check_node_health') def test_get_chain_status_all_dead(self, mock_health): """Статус цепочки: все мертвы""" mock_health.return_value = False le = LeaderElection() status = le.get_chain_status() # Должны быть красные кружки для всех узлов self.assertIn("🔴", status) self.assertNotIn("🟢", status) @patch('leader_election.check_node_health') @patch.dict(os.environ, {'MONTANA_NODE_NAME': 'moscow'}) def test_get_chain_status_marks_self(self, mock_health): """Статус цепочки: отмечает свой узел""" mock_health.return_value = True le = LeaderElection() status = le.get_chain_status() # Должна быть метка "← я" для своего узла self.assertIn("moscow ← я", status) class TestLeaderElectionMasterLogic(unittest.TestCase): """Тесты логики определения мастера""" # ═══════════════════════════════════════════════════════════════════════ # AM I THE MASTER # ═══════════════════════════════════════════════════════════════════════ @patch('leader_election.check_node_health') @patch.dict(os.environ, {'MONTANA_NODE_NAME': 'amsterdam'}) def test_am_i_master_primary_always(self, mock_health): """PRIMARY (amsterdam) всегда мастер""" mock_health.return_value = True le = LeaderElection() result = le.am_i_the_master() # Amsterdam — первый в цепочке, всегда мастер self.assertTrue(result) @patch('leader_election.check_node_health') @patch.dict(os.environ, {'MONTANA_NODE_NAME': 'moscow'}) def test_am_i_master_second_when_first_dead(self, mock_health): """MIRROR 1 (moscow) — мастер если amsterdam мертв""" def health_check(ip): # Amsterdam мертв, остальные живы return ip != "72.56.102.240" mock_health.side_effect = health_check le = LeaderElection() result = le.am_i_the_master() # Moscow становится мастером self.assertTrue(result) @patch('leader_election.check_node_health') @patch.dict(os.environ, {'MONTANA_NODE_NAME': 'moscow'}) def test_am_i_master_second_when_first_alive(self, mock_health): """MIRROR 1 (moscow) НЕ мастер если amsterdam жив""" mock_health.return_value = True # Все живы le = LeaderElection() result = le.am_i_the_master() # Amsterdam жив → Moscow не мастер self.assertFalse(result) @patch('leader_election.check_node_health') @patch.dict(os.environ, {'MONTANA_NODE_NAME': 'novosibirsk'}) def test_am_i_master_last_when_all_dead(self, mock_health): """Последний узел (novosibirsk) — мастер если все выше мертвы""" mock_health.return_value = False # Все мертвы le = LeaderElection() result = le.am_i_the_master() # Все выше мертвы → Novosibirsk становится мастером self.assertTrue(result) @patch('leader_election.check_node_health') @patch.dict(os.environ, {'MONTANA_NODE_NAME': 'spb'}) def test_am_i_master_middle_selective(self, mock_health): """Средний узел (spb) — мастер только если все выше мертвы""" def health_check(ip): # Amsterdam и Moscow мертвы, Almaty жив dead_nodes = ["72.56.102.240", "176.124.208.93"] return ip not in dead_nodes mock_health.side_effect = health_check le = LeaderElection() result = le.am_i_the_master() # Almaty (выше) жив → SPB не мастер self.assertFalse(result) class TestLeaderElectionAttackHandling(unittest.TestCase): """Тесты обработки атак""" # ═══════════════════════════════════════════════════════════════════════ # CHAIN SHUFFLING # ═══════════════════════════════════════════════════════════════════════ @patch('leader_election.check_node_health') @patch.dict(os.environ, {'MONTANA_NODE_NAME': 'moscow'}) def test_shuffle_chain_on_attack(self, mock_health): """При атаке цепочка перемешивается""" mock_health.return_value = True # Все узлы живы le = LeaderElection() original_order = [name for name, _ in le.chain] # Симулируем атаку le.attack_detector.under_attack = True le.shuffle_chain_on_attack() # Цепочка должна быть перемешана new_order = [name for name, _ in le.chain] self.assertNotEqual(original_order, new_order) self.assertTrue(le.chain_shuffled) @patch('leader_election.check_node_health') def test_shuffle_filters_dead_nodes(self, mock_health): """shuffle_chain_on_attack() фильтрует мертвые узлы""" def health_check(ip): # Только Amsterdam и Moscow живы alive_nodes = ["72.56.102.240", "176.124.208.93"] return ip in alive_nodes mock_health.side_effect = health_check le = LeaderElection() le.attack_detector.under_attack = True le.shuffle_chain_on_attack() # В новой цепочке должны быть только 2 живых узла self.assertEqual(len(le.chain), 2) chain_ips = [ip for _, ip in le.chain] self.assertIn("72.56.102.240", chain_ips) self.assertIn("176.124.208.93", chain_ips) @patch('leader_election.check_node_health') def test_shuffle_no_attack_no_shuffle(self, mock_health): """Без атаки цепочка не перемешивается""" mock_health.return_value = True le = LeaderElection() original_chain = list(le.chain) # НЕТ атаки le.attack_detector.under_attack = False le.shuffle_chain_on_attack() # Цепочка НЕ изменилась self.assertEqual(le.chain, original_chain) self.assertFalse(le.chain_shuffled) # ═══════════════════════════════════════════════════════════════════════ # CHAIN RESTORATION # ═══════════════════════════════════════════════════════════════════════ @patch('leader_election.check_node_health') def test_restore_original_chain(self, mock_health): """Восстановление оригинальной цепочки""" mock_health.return_value = True le = LeaderElection() original = list(le.original_chain) # Перемешиваем le.attack_detector.under_attack = True le.shuffle_chain_on_attack() # Восстанавливаем le.restore_original_chain() # Цепочка восстановлена self.assertEqual(le.chain, original) self.assertFalse(le.chain_shuffled) def test_restore_when_not_shuffled(self): """Восстановление когда цепочка не была перемешана""" le = LeaderElection() original = list(le.chain) le.restore_original_chain() # Ничего не изменилось self.assertEqual(le.chain, original) self.assertFalse(le.chain_shuffled) class TestLeaderElectionSingleton(unittest.TestCase): """Тесты singleton паттерна""" # ═══════════════════════════════════════════════════════════════════════ # SINGLETON # ═══════════════════════════════════════════════════════════════════════ def test_get_leader_election_returns_instance(self): """get_leader_election() возвращает экземпляр""" le = get_leader_election() self.assertIsNotNone(le) self.assertIsInstance(le, LeaderElection) def test_get_leader_election_singleton(self): """get_leader_election() возвращает один и тот же экземпляр""" le1 = get_leader_election() le2 = get_leader_election() self.assertIs(le1, le2) def test_singleton_shared_state(self): """Singleton делит состояние между вызовами""" le1 = get_leader_election() le1.is_master = True le2 = get_leader_election() # Должен быть тот же объект с тем же состоянием self.assertTrue(le2.is_master) class TestLeaderElectionEdgeCases(unittest.TestCase): """Тесты граничных случаев""" # ═══════════════════════════════════════════════════════════════════════ # EDGE CASES # ═══════════════════════════════════════════════════════════════════════ def test_empty_chain(self): """Пустая цепочка""" le = LeaderElection(chain=[]) self.assertEqual(len(le.chain), 0) self.assertIsNone(le.my_name) self.assertIsNone(le.my_ip) self.assertEqual(le.my_position, -1) @patch('leader_election.check_node_health') def test_single_node_chain(self, mock_health): """Цепочка из одного узла""" mock_health.return_value = True single_chain = [("solo", "1.1.1.1")] le = LeaderElection(chain=single_chain) # Единственный узел всегда мастер self.assertTrue(le.am_i_the_master()) @patch('leader_election.check_node_health') @patch.dict(os.environ, {'MONTANA_NODE_NAME': 'unknown'}) def test_node_not_in_chain(self, mock_health): """Узел не найден в цепочке""" mock_health.return_value = True le = LeaderElection() # Должен fallback на первый узел self.assertIn(le.my_name, [name for name, _ in BOT_CHAIN]) @patch('leader_election.check_node_health') def test_all_nodes_dead_last_becomes_master(self, mock_health): """Все узлы мертвы → последний становится мастером""" mock_health.return_value = False # Все мертвы # Создаём узел как последний в цепочке with patch.dict(os.environ, {'MONTANA_NODE_NAME': 'novosibirsk'}): le = LeaderElection() result = le.am_i_the_master() # Все мертвы → последний мастер self.assertTrue(result) # ═══════════════════════════════════════════════════════════════════════════ # RUN TESTS # ═══════════════════════════════════════════════════════════════════════════ if __name__ == '__main__': # Запускаем с verbose output unittest.main(verbosity=2)