montana/Русский/Тесты/test_leader_election.py

699 lines
31 KiB
Python
Raw Normal View History

#!/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)