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

699 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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