mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-05-11 18:14:50 +00:00
313dfccf75
Make outbound send suppression consistent by honoring both radio-offline and zombie states across command-manager and scheduler paths. Preserve strict SSRF defaults while adding explicit private-feed URL opt-ins, persist allow_local_smtp from notifications config writes, reconcile zombie alert setting precedence, and replace deprecated UTC timestamp calls with timezone-aware UTC usage.
1602 lines
66 KiB
Python
1602 lines
66 KiB
Python
"""Tests for MessageScheduler pure logic (no threading, no asyncio)."""
|
||
|
||
import datetime
|
||
import time
|
||
from configparser import ConfigParser
|
||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||
|
||
import pytest
|
||
|
||
from modules.scheduler import MessageScheduler
|
||
|
||
|
||
@pytest.fixture
|
||
def scheduler(mock_logger):
|
||
"""MessageScheduler with mock bot for pure logic tests."""
|
||
bot = Mock()
|
||
bot.logger = mock_logger
|
||
bot.config = ConfigParser()
|
||
bot.config.add_section("Bot")
|
||
return MessageScheduler(bot)
|
||
|
||
|
||
class TestIsValidTimeFormat:
|
||
"""Tests for _is_valid_time_format()."""
|
||
|
||
def test_valid_time_0000(self, scheduler):
|
||
assert scheduler._is_valid_time_format("0000") is True
|
||
|
||
def test_valid_time_2359(self, scheduler):
|
||
assert scheduler._is_valid_time_format("2359") is True
|
||
|
||
def test_valid_time_1200(self, scheduler):
|
||
assert scheduler._is_valid_time_format("1200") is True
|
||
|
||
def test_invalid_time_2400(self, scheduler):
|
||
assert scheduler._is_valid_time_format("2400") is False
|
||
|
||
def test_invalid_time_0060(self, scheduler):
|
||
assert scheduler._is_valid_time_format("0060") is False
|
||
|
||
def test_invalid_time_short(self, scheduler):
|
||
assert scheduler._is_valid_time_format("123") is False
|
||
|
||
def test_invalid_time_letters(self, scheduler):
|
||
assert scheduler._is_valid_time_format("abcd") is False
|
||
|
||
def test_invalid_time_empty(self, scheduler):
|
||
assert scheduler._is_valid_time_format("") is False
|
||
|
||
|
||
class TestGetCurrentTime:
|
||
"""Tests for timezone-aware time retrieval."""
|
||
|
||
def test_valid_timezone(self, scheduler):
|
||
scheduler.bot.config.set("Bot", "timezone", "US/Pacific")
|
||
result = scheduler.get_current_time()
|
||
assert result.tzinfo is not None
|
||
|
||
def test_invalid_timezone_falls_back(self, scheduler):
|
||
scheduler.bot.config.set("Bot", "timezone", "Invalid/Zone")
|
||
result = scheduler.get_current_time()
|
||
# Should still return a datetime (system time fallback)
|
||
assert result is not None
|
||
scheduler.bot.logger.warning.assert_called()
|
||
|
||
def test_empty_timezone_uses_system(self, scheduler):
|
||
scheduler.bot.config.set("Bot", "timezone", "")
|
||
result = scheduler.get_current_time()
|
||
assert result is not None
|
||
|
||
|
||
class TestHasMeshInfoPlaceholders:
|
||
"""Tests for _has_mesh_info_placeholders()."""
|
||
|
||
def test_detects_placeholder(self, scheduler):
|
||
assert scheduler._has_mesh_info_placeholders("Contacts: {total_contacts}") is True
|
||
|
||
def test_no_placeholder_returns_false(self, scheduler):
|
||
assert scheduler._has_mesh_info_placeholders("Hello world") is False
|
||
|
||
def test_detects_legacy_placeholder(self, scheduler):
|
||
assert scheduler._has_mesh_info_placeholders("Repeaters: {repeaters}") is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TestSetupScheduledMessages
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSetupScheduledMessages:
|
||
"""Tests for setup_scheduled_messages() — config parsing and APScheduler job registration."""
|
||
|
||
def _setup_and_call(self, scheduler):
|
||
"""Run setup_scheduled_messages() with a real (but isolated) APScheduler."""
|
||
scheduler.setup_scheduled_messages()
|
||
|
||
def _teardown(self, scheduler):
|
||
if scheduler._apscheduler is not None:
|
||
try:
|
||
scheduler._apscheduler.shutdown(wait=False)
|
||
except Exception:
|
||
pass
|
||
|
||
def test_valid_entry_is_registered_and_stored(self, scheduler):
|
||
scheduler.bot.config.add_section("Scheduled_Messages")
|
||
scheduler.bot.config.set("Scheduled_Messages", "0900", "general: Good morning!")
|
||
self._setup_and_call(scheduler)
|
||
assert "0900" in scheduler.scheduled_messages
|
||
channel, message = scheduler.scheduled_messages["0900"]
|
||
assert channel == "general"
|
||
assert "Good morning!" in message
|
||
assert len(scheduler._apscheduler.get_jobs()) == 1
|
||
self._teardown(scheduler)
|
||
|
||
def test_invalid_time_format_is_skipped(self, scheduler):
|
||
scheduler.bot.config.add_section("Scheduled_Messages")
|
||
scheduler.bot.config.set("Scheduled_Messages", "9999", "general: Bad time")
|
||
self._setup_and_call(scheduler)
|
||
assert "9999" not in scheduler.scheduled_messages
|
||
self._teardown(scheduler)
|
||
|
||
def test_missing_colon_separator_is_skipped(self, scheduler):
|
||
scheduler.bot.config.add_section("Scheduled_Messages")
|
||
scheduler.bot.config.set("Scheduled_Messages", "0800", "no colon here")
|
||
self._setup_and_call(scheduler)
|
||
assert "0800" not in scheduler.scheduled_messages
|
||
self._teardown(scheduler)
|
||
|
||
def test_no_scheduled_messages_section_does_not_raise(self, scheduler):
|
||
# No [Scheduled_Messages] section in config
|
||
self._setup_and_call(scheduler)
|
||
assert scheduler.scheduled_messages == {}
|
||
self._teardown(scheduler)
|
||
|
||
def test_multiple_entries_all_registered(self, scheduler):
|
||
scheduler.bot.config.add_section("Scheduled_Messages")
|
||
scheduler.bot.config.set("Scheduled_Messages", "0700", "general: Morning")
|
||
scheduler.bot.config.set("Scheduled_Messages", "1200", "general: Noon")
|
||
scheduler.bot.config.set("Scheduled_Messages", "1800", "general: Evening")
|
||
self._setup_and_call(scheduler)
|
||
assert len(scheduler.scheduled_messages) == 3
|
||
assert len(scheduler._apscheduler.get_jobs()) == 3
|
||
self._teardown(scheduler)
|
||
|
||
def test_message_escape_sequences_decoded(self, scheduler):
|
||
"""\\n in config value should be decoded to a real newline in the stored message."""
|
||
scheduler.bot.config.add_section("Scheduled_Messages")
|
||
scheduler.bot.config.set("Scheduled_Messages", "1000", r"general: Line1\nLine2")
|
||
self._setup_and_call(scheduler)
|
||
_, message = scheduler.scheduled_messages["1000"]
|
||
assert "\n" in message
|
||
self._teardown(scheduler)
|
||
|
||
def test_reload_replaces_existing_jobs(self, scheduler):
|
||
"""Calling setup_scheduled_messages() twice should not duplicate jobs."""
|
||
scheduler.bot.config.add_section("Scheduled_Messages")
|
||
scheduler.bot.config.set("Scheduled_Messages", "0700", "general: Morning")
|
||
self._setup_and_call(scheduler)
|
||
self._setup_and_call(scheduler) # second call — should replace, not add
|
||
assert len(scheduler._apscheduler.get_jobs()) == 1
|
||
self._teardown(scheduler)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TestSetupIntervalAdvertising
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSetupIntervalAdvertising:
|
||
"""Tests for setup_interval_advertising()."""
|
||
|
||
def test_positive_interval_initialises_last_advert_time(self, scheduler):
|
||
scheduler.bot.config.set("Bot", "advert_interval_hours", "6")
|
||
del scheduler.bot.last_advert_time # ensure hasattr returns False
|
||
scheduler.bot.last_advert_time = None
|
||
scheduler.setup_interval_advertising()
|
||
assert scheduler.bot.last_advert_time is not None
|
||
|
||
def test_last_advert_time_not_overwritten_when_already_set(self, scheduler):
|
||
scheduler.bot.config.set("Bot", "advert_interval_hours", "6")
|
||
scheduler.bot.last_advert_time = 12345.0
|
||
scheduler.setup_interval_advertising()
|
||
assert scheduler.bot.last_advert_time == 12345.0
|
||
|
||
def test_zero_interval_logs_disabled(self, scheduler):
|
||
scheduler.bot.config.set("Bot", "advert_interval_hours", "0")
|
||
scheduler.setup_interval_advertising()
|
||
scheduler.bot.logger.info.assert_called()
|
||
|
||
def test_default_interval_zero_when_unset(self, scheduler):
|
||
# advert_interval_hours not in config → fallback 0 → disabled
|
||
scheduler.setup_interval_advertising()
|
||
scheduler.bot.logger.info.assert_called()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# check_interval_advertising
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestCheckIntervalAdvertising:
|
||
|
||
def test_disabled_when_interval_zero(self, scheduler):
|
||
scheduler.bot.config.set("Bot", "advert_interval_hours", "0")
|
||
scheduler.bot.last_advert_time = time.time() - 99999
|
||
with patch.object(scheduler, "send_interval_advert") as mock_send:
|
||
scheduler.check_interval_advertising()
|
||
mock_send.assert_not_called()
|
||
|
||
def test_first_call_sets_last_advert_time(self, scheduler):
|
||
scheduler.bot.config.set("Bot", "advert_interval_hours", "1")
|
||
scheduler.bot.last_advert_time = None
|
||
scheduler.check_interval_advertising()
|
||
assert scheduler.bot.last_advert_time is not None
|
||
|
||
def test_not_enough_time_passed_no_advert(self, scheduler):
|
||
scheduler.bot.config.set("Bot", "advert_interval_hours", "1")
|
||
scheduler.bot.last_advert_time = time.time() - 1800 # 30 min ago, need 60 min
|
||
with patch.object(scheduler, "send_interval_advert") as mock_send:
|
||
scheduler.check_interval_advertising()
|
||
mock_send.assert_not_called()
|
||
|
||
def test_enough_time_passed_sends_advert(self, scheduler):
|
||
scheduler.bot.config.set("Bot", "advert_interval_hours", "1")
|
||
scheduler.bot.last_advert_time = time.time() - 3700 # > 1 hour ago
|
||
with patch.object(scheduler, "send_interval_advert") as mock_send:
|
||
scheduler.check_interval_advertising()
|
||
mock_send.assert_called_once()
|
||
|
||
def test_last_advert_time_updated_after_send(self, scheduler):
|
||
scheduler.bot.config.set("Bot", "advert_interval_hours", "1")
|
||
old_time = time.time() - 3700
|
||
scheduler.bot.last_advert_time = old_time
|
||
with patch.object(scheduler, "send_interval_advert"):
|
||
scheduler.check_interval_advertising()
|
||
assert scheduler.bot.last_advert_time > old_time
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _get_notif / _get_maint
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestGetNotifAndMaint:
|
||
|
||
def test_get_notif_returns_value(self, scheduler):
|
||
scheduler.bot.db_manager = Mock()
|
||
scheduler.bot.db_manager.get_metadata = Mock(return_value="smtp.example.com")
|
||
assert scheduler._get_notif("smtp_host") == "smtp.example.com"
|
||
scheduler.bot.db_manager.get_metadata.assert_called_with("notif.smtp_host")
|
||
|
||
def test_get_notif_returns_empty_on_none(self, scheduler):
|
||
scheduler.bot.db_manager = Mock()
|
||
scheduler.bot.db_manager.get_metadata = Mock(return_value=None)
|
||
assert scheduler._get_notif("smtp_host") == ""
|
||
|
||
def test_get_notif_returns_empty_on_exception(self, scheduler):
|
||
scheduler.bot.db_manager = Mock()
|
||
scheduler.bot.db_manager.get_metadata = Mock(side_effect=Exception("db error"))
|
||
assert scheduler._get_notif("smtp_host") == ""
|
||
|
||
def test_get_maint_returns_value(self, scheduler):
|
||
scheduler.bot.db_manager = Mock()
|
||
scheduler.bot.db_manager.get_metadata = Mock(return_value="daily")
|
||
assert scheduler._get_maint("db_backup_schedule") == "daily"
|
||
scheduler.bot.db_manager.get_metadata.assert_called_with("maint.db_backup_schedule")
|
||
|
||
def test_get_maint_returns_empty_on_none(self, scheduler):
|
||
scheduler.bot.db_manager = Mock()
|
||
scheduler.bot.db_manager.get_metadata = Mock(return_value=None)
|
||
assert scheduler._get_maint("db_backup_enabled") == ""
|
||
|
||
def test_get_maint_returns_empty_on_exception(self, scheduler):
|
||
scheduler.bot.db_manager = Mock()
|
||
scheduler.bot.db_manager.get_metadata = Mock(side_effect=RuntimeError("fail"))
|
||
assert scheduler._get_maint("any") == ""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _format_email_body
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _maybe_run_db_backup
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestMaybeRunDbBackup:
|
||
|
||
def _setup(self, scheduler, enabled="true", schedule="daily",
|
||
time_str="02:00", last_ran=""):
|
||
def maint(key):
|
||
return {
|
||
"db_backup_enabled": enabled,
|
||
"db_backup_schedule": schedule,
|
||
"db_backup_time": time_str,
|
||
"db_backup_retention_count": "7",
|
||
"db_backup_dir": "/tmp/backup",
|
||
}.get(key, "")
|
||
scheduler.maintenance.get_maint = Mock(side_effect=maint)
|
||
scheduler._last_db_backup_stats = {"ran_at": last_ran}
|
||
|
||
def test_disabled_does_not_run(self, scheduler):
|
||
self._setup(scheduler, enabled="false")
|
||
with patch.object(scheduler.maintenance, "run_db_backup") as mock_run:
|
||
scheduler._maybe_run_db_backup()
|
||
mock_run.assert_not_called()
|
||
|
||
def test_manual_schedule_does_not_run(self, scheduler):
|
||
self._setup(scheduler, schedule="manual")
|
||
with patch.object(scheduler.maintenance, "run_db_backup") as mock_run:
|
||
scheduler._maybe_run_db_backup()
|
||
mock_run.assert_not_called()
|
||
|
||
def test_already_ran_today_does_not_run(self, scheduler):
|
||
now = datetime.datetime.now()
|
||
today = now.strftime("%Y-%m-%d")
|
||
# Schedule 1 minute ago (inside window), but mark as already run today
|
||
sched_time = now - datetime.timedelta(minutes=1)
|
||
time_str = sched_time.strftime("%H:%M")
|
||
self._setup(scheduler, time_str=time_str, last_ran=f"{today}T00:01:00")
|
||
with patch.object(scheduler.maintenance, "run_db_backup") as mock_run:
|
||
scheduler._maybe_run_db_backup()
|
||
mock_run.assert_not_called()
|
||
|
||
def test_runs_within_fire_window(self, scheduler):
|
||
"""Backup fires when now is within 2 minutes of the scheduled time."""
|
||
now = datetime.datetime.now()
|
||
# Set scheduled time to 1 minute ago so we're inside the 2-min window
|
||
sched_time = now - datetime.timedelta(minutes=1)
|
||
time_str = sched_time.strftime("%H:%M")
|
||
yesterday = (now - datetime.timedelta(days=1)).strftime("%Y-%m-%d")
|
||
self._setup(scheduler, time_str=time_str, last_ran=f"{yesterday}T00:01:00")
|
||
with patch.object(scheduler.maintenance, "run_db_backup") as mock_run:
|
||
scheduler._maybe_run_db_backup()
|
||
mock_run.assert_called_once()
|
||
|
||
def test_does_not_run_outside_fire_window(self, scheduler):
|
||
"""Backup does NOT fire when the scheduled time passed more than 2 minutes ago."""
|
||
now = datetime.datetime.now()
|
||
# Set scheduled time to 5 minutes ago — outside the 2-min window
|
||
sched_time = now - datetime.timedelta(minutes=5)
|
||
time_str = sched_time.strftime("%H:%M")
|
||
yesterday = (now - datetime.timedelta(days=1)).strftime("%Y-%m-%d")
|
||
self._setup(scheduler, time_str=time_str, last_ran=f"{yesterday}T00:01:00")
|
||
with patch.object(scheduler.maintenance, "run_db_backup") as mock_run:
|
||
scheduler._maybe_run_db_backup()
|
||
mock_run.assert_not_called()
|
||
|
||
def test_does_not_run_before_scheduled_time(self, scheduler):
|
||
"""Backup does NOT fire when the scheduled time is in the future."""
|
||
now = datetime.datetime.now()
|
||
sched_time = now + datetime.timedelta(minutes=30)
|
||
time_str = sched_time.strftime("%H:%M")
|
||
self._setup(scheduler, time_str=time_str, last_ran="")
|
||
with patch.object(scheduler.maintenance, "run_db_backup") as mock_run:
|
||
scheduler._maybe_run_db_backup()
|
||
mock_run.assert_not_called()
|
||
|
||
def test_weekly_on_wrong_day_does_not_run(self, scheduler):
|
||
# Use a time 1 min ago (inside the 2-min fire window) on a Tuesday
|
||
now = datetime.datetime.now()
|
||
sched_time = now - datetime.timedelta(minutes=1)
|
||
time_str = sched_time.strftime("%H:%M")
|
||
self._setup(scheduler, schedule="weekly", time_str=time_str, last_ran="")
|
||
fake_now = Mock()
|
||
fake_now.weekday.return_value = 1 # Tuesday — not Monday
|
||
scheduled_dt = now.replace(
|
||
hour=sched_time.hour, minute=sched_time.minute, second=0, microsecond=0
|
||
)
|
||
fake_now.replace.return_value = scheduled_dt
|
||
fake_now.__gt__ = lambda s, o: False # inside window
|
||
fake_now.__lt__ = lambda s, o: False
|
||
fake_now.__sub__ = lambda s, o: now - o # for timedelta comparison
|
||
fake_now.strftime = now.strftime
|
||
fake_now.isocalendar.return_value = (2026, 11, 2)
|
||
with patch.object(scheduler, "get_current_time", return_value=fake_now):
|
||
with patch.object(scheduler.maintenance, "_get_current_time", return_value=fake_now):
|
||
with patch.object(scheduler.maintenance, "run_db_backup") as mock_run:
|
||
scheduler._maybe_run_db_backup()
|
||
mock_run.assert_not_called()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _apply_log_rotation_config
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestApplyLogRotationConfig:
|
||
|
||
def test_no_maint_settings_returns_early(self, scheduler):
|
||
scheduler.bot.db_manager = Mock()
|
||
scheduler.bot.db_manager.get_metadata = Mock(return_value=None)
|
||
# No logger handlers to worry about
|
||
scheduler._apply_log_rotation_config() # Should not raise
|
||
|
||
def test_same_settings_not_reapplied(self, scheduler):
|
||
scheduler.bot.db_manager = Mock()
|
||
scheduler.bot.db_manager.get_metadata = Mock(side_effect=lambda k: {
|
||
"maint.log_max_bytes": "5242880",
|
||
"maint.log_backup_count": "3",
|
||
}.get(k))
|
||
scheduler._last_log_rotation_applied = {
|
||
"max_bytes": "5242880",
|
||
"backup_count": "3",
|
||
}
|
||
# No RotatingFileHandler in mock logger
|
||
scheduler.bot.logger.handlers = []
|
||
scheduler._apply_log_rotation_config() # Should not raise or modify
|
||
|
||
def test_invalid_value_logs_warning(self, scheduler):
|
||
scheduler.bot.db_manager = Mock()
|
||
scheduler.bot.db_manager.get_metadata = Mock(side_effect=lambda k: {
|
||
"maint.log_max_bytes": "not-a-number",
|
||
"maint.log_backup_count": "3",
|
||
}.get(k))
|
||
scheduler._last_log_rotation_applied = {}
|
||
scheduler.bot.logger.handlers = []
|
||
scheduler._apply_log_rotation_config()
|
||
scheduler.bot.logger.warning.assert_called()
|
||
|
||
def test_rotating_handler_replaced(self, scheduler):
|
||
import os
|
||
import tempfile
|
||
from logging.handlers import RotatingFileHandler
|
||
scheduler.bot.db_manager = Mock()
|
||
scheduler.bot.db_manager.get_metadata = Mock(side_effect=lambda k: {
|
||
"maint.log_max_bytes": "10485760",
|
||
"maint.log_backup_count": "5",
|
||
}.get(k))
|
||
scheduler._last_log_rotation_applied = {}
|
||
|
||
# Create a real RotatingFileHandler pointed at a temp file
|
||
with tempfile.NamedTemporaryFile(delete=False, suffix=".log") as tf:
|
||
tmp_path = tf.name
|
||
try:
|
||
handler = RotatingFileHandler(tmp_path, maxBytes=1024, backupCount=1)
|
||
scheduler.bot.logger.handlers = [handler]
|
||
scheduler._apply_log_rotation_config()
|
||
new_handler = scheduler.bot.logger.handlers[0]
|
||
assert new_handler.maxBytes == 10485760
|
||
assert new_handler.backupCount == 5
|
||
new_handler.close()
|
||
finally:
|
||
os.unlink(tmp_path)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TestAPSchedulerLifecycle
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestAPSchedulerLifecycle:
|
||
"""Tests for APScheduler start/shutdown lifecycle in MessageScheduler."""
|
||
|
||
def test_apscheduler_created_on_setup(self, scheduler):
|
||
scheduler.setup_scheduled_messages()
|
||
assert scheduler._apscheduler is not None
|
||
assert scheduler._apscheduler.running
|
||
scheduler.join(timeout=1)
|
||
|
||
def test_join_shuts_down_apscheduler(self, scheduler):
|
||
scheduler.setup_scheduled_messages()
|
||
assert scheduler._apscheduler.running
|
||
scheduler.join(timeout=1)
|
||
assert not scheduler._apscheduler.running
|
||
|
||
def test_join_with_no_apscheduler_does_not_raise(self, scheduler):
|
||
assert scheduler._apscheduler is None
|
||
scheduler.join(timeout=0.1) # must not raise
|
||
|
||
def test_cron_trigger_hour_minute(self, scheduler):
|
||
"""Registered jobs get a CronTrigger with the correct hour/minute."""
|
||
scheduler.bot.config.add_section("Scheduled_Messages")
|
||
scheduler.bot.config.set("Scheduled_Messages", "1430", "ch: hello")
|
||
scheduler.setup_scheduled_messages()
|
||
jobs = scheduler._apscheduler.get_jobs()
|
||
assert len(jobs) == 1
|
||
trigger = jobs[0].trigger
|
||
# CronTrigger fields: hour=14, minute=30
|
||
field_map = {f.name: f for f in trigger.fields}
|
||
assert str(field_map["hour"]) == "14"
|
||
assert str(field_map["minute"]) == "30"
|
||
scheduler.join(timeout=1)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TASK-05 / BUG-024: last_db_backup_run updated after _maybe_run_db_backup
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestDbBackupIntervalGuard:
|
||
"""Verify last_db_backup_run is updated so the 300s guard works correctly."""
|
||
|
||
def test_last_db_backup_run_updated_after_call(self, scheduler):
|
||
"""last_db_backup_run is set to ~now immediately after _maybe_run_db_backup."""
|
||
scheduler.last_db_backup_run = 0 # force guard to fire
|
||
|
||
with patch.object(scheduler, '_maybe_run_db_backup') as mock_backup:
|
||
before = time.time()
|
||
# Simulate the scheduler loop body: guard fires, backup runs, timestamp updated
|
||
if time.time() - scheduler.last_db_backup_run >= 300:
|
||
scheduler._maybe_run_db_backup()
|
||
scheduler.last_db_backup_run = time.time()
|
||
after = time.time()
|
||
|
||
mock_backup.assert_called_once()
|
||
assert scheduler.last_db_backup_run >= before
|
||
assert scheduler.last_db_backup_run <= after
|
||
|
||
def test_guard_prevents_second_call_within_300s(self, scheduler):
|
||
"""After last_db_backup_run is updated, a second loop iteration does not call backup."""
|
||
scheduler.last_db_backup_run = 0
|
||
|
||
call_count = 0
|
||
|
||
def fake_backup():
|
||
nonlocal call_count
|
||
call_count += 1
|
||
scheduler.last_db_backup_run = time.time() # mirrors fixed scheduler code
|
||
|
||
with patch.object(scheduler, '_maybe_run_db_backup', side_effect=fake_backup):
|
||
# First iteration — guard fires
|
||
if time.time() - scheduler.last_db_backup_run >= 300:
|
||
scheduler._maybe_run_db_backup()
|
||
scheduler.last_db_backup_run = time.time()
|
||
|
||
# Second iteration immediately after — guard must NOT fire
|
||
if time.time() - scheduler.last_db_backup_run >= 300:
|
||
scheduler._maybe_run_db_backup()
|
||
scheduler.last_db_backup_run = time.time()
|
||
|
||
assert call_count == 1, "Backup should only run once; guard failed to prevent second call"
|
||
|
||
def test_initial_last_db_backup_run_is_zero(self, scheduler):
|
||
"""last_db_backup_run starts at 0 so first check fires after 300s uptime."""
|
||
assert scheduler.last_db_backup_run == 0
|
||
|
||
def test_guard_fires_after_300s(self, scheduler):
|
||
"""Guard fires when last_db_backup_run is more than 300s in the past."""
|
||
scheduler.last_db_backup_run = time.time() - 301
|
||
assert time.time() - scheduler.last_db_backup_run >= 300
|
||
|
||
def test_guard_does_not_fire_before_300s(self, scheduler):
|
||
"""Guard does not fire when last run was less than 300s ago."""
|
||
scheduler.last_db_backup_run = time.time() - 10
|
||
assert not (time.time() - scheduler.last_db_backup_run >= 300)
|
||
|
||
def test_restart_seeds_last_ran_from_db(self, scheduler):
|
||
"""On first _maybe_run_db_backup call after restart, ran_at is loaded from DB metadata."""
|
||
now = datetime.datetime.now()
|
||
today = now.strftime("%Y-%m-%d")
|
||
# DB says backup ran today
|
||
scheduler.bot.db_manager.get_metadata.return_value = f"{today}T01:00:00"
|
||
scheduler._last_db_backup_stats = {}
|
||
|
||
# Schedule 1 min ago (inside 2-min window) to ensure we'd run if not for dedup
|
||
sched_time = now - datetime.timedelta(minutes=1)
|
||
time_str = sched_time.strftime("%H:%M")
|
||
|
||
def maint(key):
|
||
return {
|
||
"db_backup_enabled": "true",
|
||
"db_backup_schedule": "daily",
|
||
"db_backup_time": time_str,
|
||
"db_backup_retention_count": "7",
|
||
"db_backup_dir": "/tmp",
|
||
}.get(key, "")
|
||
scheduler.maintenance.get_maint = Mock(side_effect=maint)
|
||
|
||
with patch.object(scheduler.maintenance, "run_db_backup") as mock_run:
|
||
scheduler._maybe_run_db_backup()
|
||
# Should NOT run because DB says it already ran today
|
||
mock_run.assert_not_called()
|
||
# And ran_at should be seeded from DB
|
||
assert scheduler._last_db_backup_stats.get("ran_at", "").startswith(today)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _format_email_body — pure logic, no external calls
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestFormatEmailBodyPure:
|
||
"""Tests for _format_email_body — pure string builder."""
|
||
|
||
def setup_method(self):
|
||
bot = Mock()
|
||
bot.logger = Mock()
|
||
bot.config = ConfigParser()
|
||
bot.config.add_section("Bot")
|
||
bot.connected = True
|
||
self.scheduler = MessageScheduler(bot)
|
||
|
||
def _basic_stats(self):
|
||
return {
|
||
"uptime": "2d 3h",
|
||
"contacts_24h": 5,
|
||
"contacts_new_24h": 1,
|
||
"contacts_total": 42,
|
||
"db_size_mb": "12.3",
|
||
"errors_24h": 0,
|
||
"criticals_24h": 0,
|
||
}
|
||
|
||
def test_returns_string(self):
|
||
result = self.scheduler._format_email_body(self._basic_stats(), "2026-01-01 00:00", "2026-01-02 00:00")
|
||
assert isinstance(result, str)
|
||
|
||
def test_contains_period(self):
|
||
result = self.scheduler._format_email_body(self._basic_stats(), "start", "end")
|
||
assert "start" in result
|
||
assert "end" in result
|
||
|
||
def test_contains_uptime(self):
|
||
result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
|
||
assert "2d 3h" in result
|
||
|
||
def test_contains_db_section(self):
|
||
result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
|
||
assert "DATABASE" in result
|
||
assert "12.3" in result
|
||
|
||
def test_contains_error_section(self):
|
||
result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
|
||
assert "ERRORS" in result
|
||
|
||
def test_bot_connected_yes(self):
|
||
self.scheduler.bot.connected = True
|
||
result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
|
||
assert "yes" in result
|
||
|
||
def test_bot_connected_no(self):
|
||
self.scheduler.bot.connected = False
|
||
result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
|
||
assert "no" in result
|
||
|
||
def test_retention_ran_at_included(self):
|
||
self.scheduler._last_retention_stats = {"ran_at": "2026-01-01T03:00:00"}
|
||
result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
|
||
assert "2026-01-01T03:00:00" in result
|
||
|
||
def test_retention_error_included(self):
|
||
self.scheduler._last_retention_stats = {"error": "DB locked"}
|
||
result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
|
||
assert "DB locked" in result
|
||
|
||
def test_log_file_section_included(self):
|
||
stats = self._basic_stats()
|
||
stats["log_file"] = "/var/log/bot.log"
|
||
stats["log_size_mb"] = "5.0"
|
||
result = self.scheduler._format_email_body(stats, "s", "e")
|
||
assert "/var/log/bot.log" in result
|
||
assert "5.0" in result
|
||
|
||
def test_log_rotated_yes(self):
|
||
stats = self._basic_stats()
|
||
stats["log_file"] = "/var/log/bot.log"
|
||
stats["log_size_mb"] = "5.0"
|
||
stats["log_rotated_24h"] = True
|
||
stats["log_backup_size_mb"] = "4.9"
|
||
result = self.scheduler._format_email_body(stats, "s", "e")
|
||
assert "yes" in result
|
||
assert "4.9" in result
|
||
|
||
def test_log_rotated_no(self):
|
||
stats = self._basic_stats()
|
||
stats["log_file"] = "/var/log/bot.log"
|
||
stats["log_size_mb"] = "5.0"
|
||
stats["log_rotated_24h"] = False
|
||
result = self.scheduler._format_email_body(stats, "s", "e")
|
||
assert "Rotated : no" in result
|
||
|
||
def test_missing_optional_stats_use_nap(self):
|
||
result = self.scheduler._format_email_body({}, "s", "e")
|
||
assert "n/a" in result or "unknown" in result
|
||
|
||
def test_no_log_file_no_log_section(self):
|
||
stats = self._basic_stats()
|
||
result = self.scheduler._format_email_body(stats, "s", "e")
|
||
assert "LOG FILES" not in result
|
||
|
||
def test_ends_with_config_hint(self):
|
||
result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
|
||
assert "/config" in result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _send_nightly_email disabled path (no smtplib)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSendNightlyEmailDisabled:
|
||
def test_disabled_returns_immediately(self):
|
||
bot = Mock()
|
||
bot.logger = Mock()
|
||
bot.config = ConfigParser()
|
||
bot.config.add_section("Bot")
|
||
scheduler = MessageScheduler(bot)
|
||
|
||
def _get_notif(key):
|
||
return {"nightly_enabled": "false"}.get(key, "")
|
||
|
||
scheduler.maintenance.get_notif = Mock(side_effect=_get_notif)
|
||
# Should not raise and should not call smtplib
|
||
scheduler.maintenance.send_nightly_email()
|
||
# No assertion needed — if it reaches here without smtplib, it returned early
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helper — shared bot + scheduler factory used by several new test classes
|
||
# ---------------------------------------------------------------------------
|
||
|
||
import asyncio
|
||
import configparser as _configparser
|
||
|
||
|
||
def _make_scheduler():
|
||
"""Return a MessageScheduler with a fully-mocked bot, skipping setup_scheduled_messages."""
|
||
bot = MagicMock()
|
||
bot.connected = True
|
||
bot.logger = Mock()
|
||
config = _configparser.ConfigParser()
|
||
config.add_section("Bot")
|
||
config.set("Bot", "advert_interval_hours", "0")
|
||
bot.config = config
|
||
bot.main_event_loop = None
|
||
bot.is_radio_zombie = False
|
||
bot.is_radio_offline = False
|
||
|
||
# db_manager.connection() context manager
|
||
conn_mock = MagicMock()
|
||
conn_mock.__enter__ = Mock(return_value=conn_mock)
|
||
conn_mock.__exit__ = Mock(return_value=False)
|
||
cursor_mock = MagicMock()
|
||
cursor_mock.fetchone.return_value = None
|
||
cursor_mock.fetchall.return_value = []
|
||
conn_mock.cursor.return_value = cursor_mock
|
||
bot.db_manager.connection.return_value = conn_mock
|
||
|
||
with patch.object(MessageScheduler, "setup_scheduled_messages"):
|
||
scheduler = MessageScheduler(bot)
|
||
return scheduler
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TestGetMeshInfo
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestGetMeshInfo:
|
||
"""Tests for _get_mesh_info() async method (lines 152–293)."""
|
||
|
||
def test_returns_dict_with_required_keys(self):
|
||
scheduler = _make_scheduler()
|
||
# Remove repeater_manager so we fall to the fallback path
|
||
del scheduler.bot.repeater_manager
|
||
result = asyncio.run(scheduler._get_mesh_info())
|
||
required = [
|
||
"total_contacts",
|
||
"total_repeaters",
|
||
"total_companions",
|
||
"total_roomservers",
|
||
"total_sensors",
|
||
"recent_activity_24h",
|
||
"new_companions_7d",
|
||
"new_repeaters_7d",
|
||
]
|
||
for key in required:
|
||
assert key in result
|
||
|
||
def test_uses_repeater_manager_stats_when_available(self):
|
||
scheduler = _make_scheduler()
|
||
stats_payload = {
|
||
"total_heard": 15,
|
||
"by_role": {
|
||
"repeater": 3,
|
||
"companion": 10,
|
||
"roomserver": 1,
|
||
"sensor": 1,
|
||
},
|
||
"recent_activity": 7,
|
||
}
|
||
scheduler.bot.repeater_manager.get_contact_statistics = AsyncMock(
|
||
return_value=stats_payload
|
||
)
|
||
result = asyncio.run(scheduler._get_mesh_info())
|
||
assert result["total_contacts"] == 15
|
||
assert result["total_repeaters"] == 3
|
||
assert result["total_companions"] == 10
|
||
assert result["recent_activity_24h"] == 7
|
||
|
||
def test_fallback_to_meshcore_contacts_when_repeater_manager_absent(self):
|
||
scheduler = _make_scheduler()
|
||
del scheduler.bot.repeater_manager
|
||
scheduler.bot.meshcore.contacts = {"a": {}, "b": {}, "c": {}}
|
||
result = asyncio.run(scheduler._get_mesh_info())
|
||
assert result["total_contacts"] == 3
|
||
|
||
def test_fallback_counts_repeaters_and_companions_when_repeater_manager_present(self):
|
||
"""When repeater_manager returns 0 total_heard, falls back to meshcore.contacts
|
||
and uses repeater_manager._is_repeater_device to classify."""
|
||
scheduler = _make_scheduler()
|
||
scheduler.bot.repeater_manager.get_contact_statistics = AsyncMock(
|
||
return_value={"total_heard": 0, "by_role": {}, "recent_activity": 0}
|
||
)
|
||
scheduler.bot.meshcore.contacts = {
|
||
"key1": {"type": "repeater"},
|
||
"key2": {"type": "companion"},
|
||
"key3": {"type": "companion"},
|
||
}
|
||
|
||
def _is_repeater(contact_data):
|
||
return contact_data.get("type") == "repeater"
|
||
|
||
scheduler.bot.repeater_manager._is_repeater_device = Mock(side_effect=_is_repeater)
|
||
result = asyncio.run(scheduler._get_mesh_info())
|
||
assert result["total_contacts"] == 3
|
||
assert result["total_repeaters"] == 1
|
||
assert result["total_companions"] == 2
|
||
|
||
def test_db_complete_contact_tracking_populates_7d_new_counts(self):
|
||
"""When complete_contact_tracking table exists, role rows are mapped to new_*_7d keys."""
|
||
scheduler = _make_scheduler()
|
||
del scheduler.bot.repeater_manager
|
||
del scheduler.bot.meshcore
|
||
|
||
# Simulate DB: first fetchone for message_stats table → None (no table)
|
||
# then inner block: fetchone for complete_contact_tracking → row
|
||
# fetchall for 7d roles → companion + repeater rows
|
||
# fetchone for 30d total → 5
|
||
# fetchall for 30d roles → empty
|
||
conn_mock = MagicMock()
|
||
conn_mock.__enter__ = Mock(return_value=conn_mock)
|
||
conn_mock.__exit__ = Mock(return_value=False)
|
||
|
||
# Track cursor().fetchone() calls — first returns None (no message_stats),
|
||
# second returns a row (complete_contact_tracking exists), third returns (5,) for 30d total
|
||
fetchone_seq = iter([None, ("complete_contact_tracking",), (5,)])
|
||
cursor_mock = MagicMock()
|
||
cursor_mock.fetchone.side_effect = lambda: next(fetchone_seq)
|
||
cursor_mock.fetchall.side_effect = [
|
||
# 7d new devices by role
|
||
[("companion", 4), ("repeater", 2), ("roomserver", 1), ("sensor", 0)],
|
||
# 30d active by role
|
||
[],
|
||
]
|
||
conn_mock.cursor.return_value = cursor_mock
|
||
scheduler.bot.db_manager.connection.return_value = conn_mock
|
||
|
||
result = asyncio.run(scheduler._get_mesh_info())
|
||
assert result["new_companions_7d"] == 4
|
||
assert result["new_repeaters_7d"] == 2
|
||
assert result["new_roomservers_7d"] == 1
|
||
assert result["total_contacts_30d"] == 5
|
||
|
||
def test_db_exception_returns_zeroed_dict_gracefully(self):
|
||
"""When db_manager.connection() raises, method still returns a dict without crashing."""
|
||
scheduler = _make_scheduler()
|
||
del scheduler.bot.repeater_manager
|
||
del scheduler.bot.meshcore
|
||
scheduler.bot.db_manager.connection.side_effect = Exception("DB unavailable")
|
||
result = asyncio.run(scheduler._get_mesh_info())
|
||
assert isinstance(result, dict)
|
||
assert result["total_contacts"] == 0
|
||
|
||
def test_repeater_manager_exception_falls_through(self):
|
||
"""Exception in get_contact_statistics is caught; method returns partial dict."""
|
||
scheduler = _make_scheduler()
|
||
scheduler.bot.repeater_manager.get_contact_statistics = AsyncMock(
|
||
side_effect=RuntimeError("timeout")
|
||
)
|
||
del scheduler.bot.meshcore
|
||
result = asyncio.run(scheduler._get_mesh_info())
|
||
assert isinstance(result, dict)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TestSendScheduledMessageAsync
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSendScheduledMessageAsync:
|
||
"""Tests for _send_scheduled_message_async() (lines 308–328)."""
|
||
|
||
def test_no_placeholders_calls_send_channel_message_directly(self):
|
||
scheduler = _make_scheduler()
|
||
scheduler.bot.command_manager.send_channel_message = AsyncMock()
|
||
asyncio.run(scheduler._send_scheduled_message_async("general", "Hello world"))
|
||
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
|
||
"general", "Hello world"
|
||
)
|
||
|
||
def test_with_placeholder_calls_get_mesh_info_and_formats(self):
|
||
scheduler = _make_scheduler()
|
||
scheduler.bot.command_manager.send_channel_message = AsyncMock()
|
||
mesh_data = {
|
||
"total_contacts": 42,
|
||
"total_repeaters": 5,
|
||
"total_companions": 37,
|
||
"total_roomservers": 0,
|
||
"total_sensors": 0,
|
||
"recent_activity_24h": 10,
|
||
"new_companions_7d": 1,
|
||
"new_repeaters_7d": 0,
|
||
"new_roomservers_7d": 0,
|
||
"new_sensors_7d": 0,
|
||
"total_contacts_30d": 40,
|
||
"total_repeaters_30d": 4,
|
||
"total_companions_30d": 36,
|
||
"total_roomservers_30d": 0,
|
||
"total_sensors_30d": 0,
|
||
}
|
||
with patch.object(
|
||
scheduler, "_get_mesh_info", new=AsyncMock(return_value=mesh_data)
|
||
):
|
||
with patch(
|
||
"modules.scheduler.format_keyword_response_with_placeholders",
|
||
return_value="Contacts: 42",
|
||
) as mock_fmt:
|
||
asyncio.run(
|
||
scheduler._send_scheduled_message_async(
|
||
"general", "Contacts: {total_contacts}"
|
||
)
|
||
)
|
||
mock_fmt.assert_called_once()
|
||
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
|
||
"general", "Contacts: 42"
|
||
)
|
||
|
||
def test_get_mesh_info_exception_sends_message_as_is(self):
|
||
"""When _get_mesh_info raises, the original message is still sent."""
|
||
scheduler = _make_scheduler()
|
||
scheduler.bot.command_manager.send_channel_message = AsyncMock()
|
||
with patch.object(
|
||
scheduler,
|
||
"_get_mesh_info",
|
||
new=AsyncMock(side_effect=Exception("mesh unavailable")),
|
||
):
|
||
asyncio.run(
|
||
scheduler._send_scheduled_message_async(
|
||
"general", "Active: {total_contacts}"
|
||
)
|
||
)
|
||
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
|
||
"general", "Active: {total_contacts}"
|
||
)
|
||
|
||
def test_format_placeholder_exception_sends_message_as_is(self):
|
||
"""When format_keyword_response_with_placeholders raises KeyError, original message is sent."""
|
||
scheduler = _make_scheduler()
|
||
scheduler.bot.command_manager.send_channel_message = AsyncMock()
|
||
with patch.object(
|
||
scheduler,
|
||
"_get_mesh_info",
|
||
new=AsyncMock(return_value={}),
|
||
):
|
||
with patch(
|
||
"modules.scheduler.format_keyword_response_with_placeholders",
|
||
side_effect=KeyError("missing_key"),
|
||
):
|
||
asyncio.run(
|
||
scheduler._send_scheduled_message_async(
|
||
"alerts", "Count: {total_contacts}"
|
||
)
|
||
)
|
||
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
|
||
"alerts", "Count: {total_contacts}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TestSendScheduledMessageWrapper
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSendScheduledMessageWrapper:
|
||
"""Tests for the sync send_scheduled_message() wrapper (lines 121–150)."""
|
||
|
||
def test_uses_run_coroutine_threadsafe_when_main_loop_running(self):
|
||
scheduler = _make_scheduler()
|
||
mock_loop = Mock()
|
||
mock_loop.is_running.return_value = True
|
||
scheduler.bot.main_event_loop = mock_loop
|
||
|
||
fake_future = Mock()
|
||
fake_future.result.return_value = None
|
||
|
||
def _run_coro_threadsafe(coro, loop):
|
||
coro.close()
|
||
return fake_future
|
||
|
||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_run_coro_threadsafe) as mock_rct:
|
||
scheduler.send_scheduled_message("general", "hi")
|
||
|
||
mock_rct.assert_called_once()
|
||
fake_future.result.assert_called_once_with(timeout=60)
|
||
|
||
def test_logs_error_when_future_result_raises(self):
|
||
scheduler = _make_scheduler()
|
||
mock_loop = Mock()
|
||
mock_loop.is_running.return_value = True
|
||
scheduler.bot.main_event_loop = mock_loop
|
||
|
||
fake_future = Mock()
|
||
fake_future.result.side_effect = Exception("timeout")
|
||
|
||
def _run_coro_threadsafe(coro, loop):
|
||
coro.close()
|
||
return fake_future
|
||
|
||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_run_coro_threadsafe):
|
||
scheduler.send_scheduled_message("general", "hi")
|
||
|
||
scheduler.bot.logger.error.assert_called()
|
||
|
||
def test_fallback_to_event_loop_when_no_main_loop(self):
|
||
scheduler = _make_scheduler()
|
||
scheduler.bot.main_event_loop = None
|
||
|
||
mock_loop = Mock()
|
||
|
||
async def _fake_send(channel: str, message: str) -> None:
|
||
return None
|
||
|
||
def _run_until_complete(coro):
|
||
if asyncio.iscoroutine(coro):
|
||
asyncio.run(coro)
|
||
|
||
mock_loop.run_until_complete = Mock(side_effect=_run_until_complete)
|
||
mock_loop.close = Mock()
|
||
|
||
with patch("asyncio.new_event_loop", return_value=mock_loop):
|
||
with patch.object(scheduler, "_send_scheduled_message_async", side_effect=_fake_send) as mock_send:
|
||
scheduler.send_scheduled_message("general", "test message")
|
||
|
||
mock_loop.run_until_complete.assert_called_once()
|
||
mock_loop.close.assert_called_once()
|
||
mock_send.assert_called_once_with("general", "test message")
|
||
|
||
def test_suppressed_when_radio_zombie(self):
|
||
scheduler = _make_scheduler()
|
||
scheduler.bot.is_radio_zombie = True
|
||
with patch("asyncio.run_coroutine_threadsafe") as mock_rct:
|
||
scheduler.send_scheduled_message("general", "hi")
|
||
mock_rct.assert_not_called()
|
||
|
||
def test_suppressed_when_radio_offline(self):
|
||
scheduler = _make_scheduler()
|
||
scheduler.bot.is_radio_offline = True
|
||
with patch("asyncio.run_coroutine_threadsafe") as mock_rct:
|
||
scheduler.send_scheduled_message("general", "hi")
|
||
mock_rct.assert_not_called()
|
||
|
||
def test_records_success_on_successful_send(self):
|
||
scheduler = _make_scheduler()
|
||
mock_loop = Mock()
|
||
mock_loop.is_running.return_value = True
|
||
scheduler.bot.main_event_loop = mock_loop
|
||
fake_future = Mock()
|
||
fake_future.result.return_value = None
|
||
def _run_coro_threadsafe(coro, loop):
|
||
coro.close()
|
||
return fake_future
|
||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_run_coro_threadsafe):
|
||
scheduler.send_scheduled_message("general", "hi")
|
||
scheduler.bot._record_send_success.assert_called_once()
|
||
|
||
def test_records_failure_on_exception(self):
|
||
scheduler = _make_scheduler()
|
||
mock_loop = Mock()
|
||
mock_loop.is_running.return_value = True
|
||
scheduler.bot.main_event_loop = mock_loop
|
||
fake_future = Mock()
|
||
fake_future.result.side_effect = Exception("bang")
|
||
def _run_coro_threadsafe(coro, loop):
|
||
coro.close()
|
||
return fake_future
|
||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_run_coro_threadsafe):
|
||
scheduler.send_scheduled_message("general", "hi")
|
||
scheduler.bot._record_send_failure.assert_called_once()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TestSendIntervalAdvertOfflineGuard
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSendIntervalAdvertOfflineGuard:
|
||
"""Tests for send_interval_advert() radio state suppression guards."""
|
||
|
||
def test_suppressed_when_radio_zombie(self):
|
||
scheduler = _make_scheduler()
|
||
scheduler.bot.is_radio_zombie = True
|
||
with patch("asyncio.run_coroutine_threadsafe") as mock_rct:
|
||
scheduler.send_interval_advert()
|
||
mock_rct.assert_not_called()
|
||
|
||
def test_suppressed_when_radio_offline(self):
|
||
scheduler = _make_scheduler()
|
||
scheduler.bot.is_radio_offline = True
|
||
with patch("asyncio.run_coroutine_threadsafe") as mock_rct:
|
||
scheduler.send_interval_advert()
|
||
mock_rct.assert_not_called()
|
||
|
||
def test_records_success_on_successful_send(self):
|
||
scheduler = _make_scheduler()
|
||
mock_loop = Mock()
|
||
mock_loop.is_running.return_value = True
|
||
scheduler.bot.main_event_loop = mock_loop
|
||
fake_future = Mock()
|
||
fake_future.result.return_value = None
|
||
def _run_coro_threadsafe(coro, loop):
|
||
coro.close()
|
||
return fake_future
|
||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_run_coro_threadsafe):
|
||
scheduler.send_interval_advert()
|
||
scheduler.bot._record_send_success.assert_called_once()
|
||
|
||
def test_records_failure_on_exception(self):
|
||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||
scheduler = _make_scheduler()
|
||
mock_loop = Mock()
|
||
mock_loop.is_running.return_value = True
|
||
scheduler.bot.main_event_loop = mock_loop
|
||
fake_future = Mock()
|
||
fake_future.result.side_effect = FuturesTimeoutError()
|
||
def _run_coro_threadsafe(coro, loop):
|
||
coro.close()
|
||
return fake_future
|
||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_run_coro_threadsafe):
|
||
scheduler.send_interval_advert()
|
||
scheduler.bot._record_send_failure.assert_called_once()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TestRunDataRetention
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestRunDataRetention:
|
||
"""Tests for _run_data_retention() (lines 501–581)."""
|
||
|
||
def _make(self):
|
||
scheduler = _make_scheduler()
|
||
# Remove all optional attributes so hasattr returns False by default
|
||
for attr in [
|
||
"web_viewer_integration",
|
||
"repeater_manager",
|
||
"command_manager",
|
||
"mesh_graph",
|
||
]:
|
||
if hasattr(scheduler.bot, attr):
|
||
delattr(scheduler.bot, attr)
|
||
return scheduler
|
||
|
||
def test_calls_web_viewer_cleanup_when_present(self):
|
||
scheduler = self._make()
|
||
bi_mock = Mock()
|
||
bi_mock.cleanup_old_data = Mock()
|
||
wvi_mock = Mock()
|
||
wvi_mock.bot_integration = bi_mock
|
||
scheduler.bot.web_viewer_integration = wvi_mock
|
||
scheduler._run_data_retention()
|
||
bi_mock.cleanup_old_data.assert_called_once()
|
||
|
||
def test_does_not_call_web_viewer_cleanup_when_absent(self):
|
||
scheduler = self._make()
|
||
# web_viewer_integration not set — should not raise
|
||
scheduler._run_data_retention() # must not raise
|
||
|
||
def test_calls_repeater_manager_cleanup_database_without_main_loop(self):
|
||
scheduler = self._make()
|
||
rm_mock = AsyncMock()
|
||
scheduler.bot.main_event_loop = None
|
||
scheduler.bot.repeater_manager = rm_mock
|
||
scheduler.bot.repeater_manager.cleanup_database = AsyncMock()
|
||
scheduler.bot.repeater_manager.cleanup_repeater_retention = Mock()
|
||
scheduler._run_data_retention()
|
||
scheduler.bot.repeater_manager.cleanup_repeater_retention.assert_called_once()
|
||
|
||
def test_calls_cleanup_expired_cache_when_present(self):
|
||
scheduler = self._make()
|
||
scheduler.bot.db_manager.cleanup_expired_cache = Mock()
|
||
scheduler._run_data_retention()
|
||
scheduler.bot.db_manager.cleanup_expired_cache.assert_called_once()
|
||
|
||
def test_calls_mesh_graph_delete_expired_edges_when_present(self):
|
||
scheduler = self._make()
|
||
mg_mock = Mock()
|
||
mg_mock.delete_expired_edges_from_db = Mock()
|
||
scheduler.bot.mesh_graph = mg_mock
|
||
scheduler._run_data_retention()
|
||
mg_mock.delete_expired_edges_from_db.assert_called_once()
|
||
|
||
def test_sets_last_retention_stats_ran_at_on_success(self):
|
||
scheduler = self._make()
|
||
scheduler._run_data_retention()
|
||
assert "ran_at" in scheduler._last_retention_stats
|
||
|
||
def test_sets_last_retention_stats_error_on_exception(self):
|
||
scheduler = self._make()
|
||
# Force an exception by making db_manager.set_metadata raise immediately
|
||
# inside the try block (cleanup_expired_cache doesn't exist, so no early raise;
|
||
# we inject via web_viewer_integration instead)
|
||
wvi_mock = Mock()
|
||
wvi_mock.bot_integration.cleanup_old_data = Mock(side_effect=RuntimeError("disk full"))
|
||
scheduler.bot.web_viewer_integration = wvi_mock
|
||
scheduler._run_data_retention()
|
||
assert "error" in scheduler._last_retention_stats
|
||
|
||
def test_no_error_when_db_manager_set_metadata_raises(self):
|
||
"""set_metadata failures after ran_at assignment should be silently swallowed."""
|
||
scheduler = self._make()
|
||
scheduler.bot.db_manager.set_metadata = Mock(side_effect=Exception("locked"))
|
||
# Should not propagate
|
||
scheduler._run_data_retention()
|
||
assert "ran_at" in scheduler._last_retention_stats
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TestCheckIntervalAdvertisingExtended
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestCheckIntervalAdvertisingExtended:
|
||
"""Additional coverage for check_interval_advertising() (lines 583–607)."""
|
||
|
||
def test_exception_logs_error(self):
|
||
scheduler = _make_scheduler()
|
||
# Force getint to raise
|
||
scheduler.bot.config.getint = Mock(side_effect=Exception("bad config"))
|
||
scheduler.check_interval_advertising()
|
||
scheduler.bot.logger.error.assert_called()
|
||
|
||
def test_last_advert_time_none_sets_timer_and_returns(self):
|
||
"""When last_advert_time is None, timer is set but no advert is sent."""
|
||
scheduler = _make_scheduler()
|
||
scheduler.bot.config.set("Bot", "advert_interval_hours", "2")
|
||
scheduler.bot.last_advert_time = None
|
||
|
||
with patch.object(scheduler, "send_interval_advert") as mock_send:
|
||
scheduler.check_interval_advertising()
|
||
|
||
mock_send.assert_not_called()
|
||
assert scheduler.bot.last_advert_time is not None
|
||
|
||
def test_missing_last_advert_time_attr_sets_timer(self):
|
||
"""When bot has no last_advert_time attribute, it gets initialised."""
|
||
scheduler = _make_scheduler()
|
||
scheduler.bot.config.set("Bot", "advert_interval_hours", "1")
|
||
# Delete the attribute so hasattr returns False
|
||
del scheduler.bot.last_advert_time
|
||
|
||
with patch.object(scheduler, "send_interval_advert") as mock_send:
|
||
scheduler.check_interval_advertising()
|
||
|
||
mock_send.assert_not_called()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TestCollectEmailStats
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestCollectEmailStats:
|
||
"""Tests for _collect_email_stats() (lines 927–1014)."""
|
||
|
||
def _scheduler_with_db(self):
|
||
scheduler = _make_scheduler()
|
||
return scheduler
|
||
|
||
def test_returns_dict_type(self):
|
||
scheduler = self._scheduler_with_db()
|
||
result = scheduler._collect_email_stats()
|
||
assert isinstance(result, dict)
|
||
|
||
def test_uptime_unknown_when_no_connection_time(self):
|
||
scheduler = self._scheduler_with_db()
|
||
# MagicMock returns truthy by default for getattr; force None
|
||
scheduler.bot.connection_time = None
|
||
result = scheduler._collect_email_stats()
|
||
assert result.get("uptime") == "unknown"
|
||
|
||
def test_uptime_computed_when_connection_time_set(self):
|
||
import time as _time
|
||
scheduler = self._scheduler_with_db()
|
||
scheduler.bot.connection_time = _time.time() - 7200 # 2 hours ago
|
||
result = scheduler._collect_email_stats()
|
||
assert "2h" in result.get("uptime", "")
|
||
|
||
def test_contacts_error_key_set_when_db_raises(self):
|
||
"""When db_manager.connection() raises, contacts_error is recorded."""
|
||
scheduler = self._scheduler_with_db()
|
||
scheduler.bot.db_manager.connection.side_effect = Exception("no DB")
|
||
result = scheduler._collect_email_stats()
|
||
assert "contacts_error" in result
|
||
|
||
def test_db_size_unknown_when_db_path_missing(self):
|
||
"""When db_path attribute is missing or stat fails, db_size_mb is 'unknown'."""
|
||
scheduler = self._scheduler_with_db()
|
||
scheduler.bot.db_manager.db_path = "/nonexistent/path/test.db"
|
||
result = scheduler._collect_email_stats()
|
||
assert result.get("db_size_mb") == "unknown"
|
||
|
||
def test_retention_key_always_present(self):
|
||
scheduler = self._scheduler_with_db()
|
||
result = scheduler._collect_email_stats()
|
||
assert "retention" in result
|
||
|
||
def test_no_log_file_in_config_skips_log_stats(self):
|
||
scheduler = self._scheduler_with_db()
|
||
# No 'Logging' section → fallback empty string → log_file not set
|
||
result = scheduler._collect_email_stats()
|
||
assert "log_file" not in result
|
||
|
||
def test_contacts_totals_from_mock_cursor(self):
|
||
"""When cursor returns plausible rows, values are mapped to contacts_* keys."""
|
||
scheduler = self._scheduler_with_db()
|
||
|
||
conn_mock = MagicMock()
|
||
conn_mock.__enter__ = Mock(return_value=conn_mock)
|
||
conn_mock.__exit__ = Mock(return_value=False)
|
||
|
||
cursor_mock = MagicMock()
|
||
cursor_mock.fetchone.side_effect = [{"n": 50}, {"n": 10}, {"n": 3}]
|
||
conn_mock.cursor.return_value = cursor_mock
|
||
scheduler.bot.db_manager.connection.return_value = conn_mock
|
||
|
||
result = scheduler._collect_email_stats()
|
||
assert result.get("contacts_total") == 50
|
||
assert result.get("contacts_24h") == 10
|
||
assert result.get("contacts_new_24h") == 3
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _send_interval_advert_async (PR2 fix — Event-based error detection)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_sched_with_logger(mock_logger):
|
||
"""Return a MessageScheduler backed by a mock bot with the given logger."""
|
||
bot = Mock()
|
||
bot.logger = mock_logger
|
||
bot.config = ConfigParser()
|
||
bot.config.add_section("Bot")
|
||
bot.is_radio_zombie = False # ensure zombie guard does not suppress sends
|
||
bot.is_radio_offline = False # ensure offline guard does not suppress sends
|
||
return MessageScheduler(bot)
|
||
|
||
|
||
class TestSendIntervalAdvertAsyncFixed:
|
||
"""Tests for MessageScheduler._send_interval_advert_async() (PR2 fix)."""
|
||
|
||
def test_error_event_raises_runtime_error(self, mock_logger):
|
||
from meshcore.events import EventType
|
||
|
||
sched = _make_sched_with_logger(mock_logger)
|
||
error_event = MagicMock()
|
||
error_event.type = EventType.ERROR
|
||
error_event.payload = {"reason": "no_event_received"}
|
||
sched.bot.meshcore.commands.send_advert = AsyncMock(return_value=error_event)
|
||
|
||
with pytest.raises(RuntimeError, match="send_advert failed"):
|
||
asyncio.run(sched._send_interval_advert_async())
|
||
|
||
def test_error_event_includes_reason_in_message(self, mock_logger):
|
||
from meshcore.events import EventType
|
||
|
||
sched = _make_sched_with_logger(mock_logger)
|
||
error_event = MagicMock()
|
||
error_event.type = EventType.ERROR
|
||
error_event.payload = {"reason": "no_event_received"}
|
||
sched.bot.meshcore.commands.send_advert = AsyncMock(return_value=error_event)
|
||
|
||
with pytest.raises(RuntimeError, match="no_event_received"):
|
||
asyncio.run(sched._send_interval_advert_async())
|
||
|
||
def test_ok_event_logs_success(self, mock_logger):
|
||
from meshcore.events import EventType
|
||
|
||
sched = _make_sched_with_logger(mock_logger)
|
||
ok_event = MagicMock()
|
||
ok_event.type = EventType.OK
|
||
sched.bot.meshcore.commands.send_advert = AsyncMock(return_value=ok_event)
|
||
|
||
asyncio.run(sched._send_interval_advert_async())
|
||
|
||
sched.bot.logger.info.assert_called_with(
|
||
"Interval-based flood advert sent successfully"
|
||
)
|
||
|
||
def test_send_interval_advert_logs_exception_type_name(self, mock_logger):
|
||
"""Error log must include type(e).__name__ so blank TimeoutError is visible."""
|
||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||
|
||
sched = _make_sched_with_logger(mock_logger)
|
||
|
||
future_mock = MagicMock()
|
||
future_mock.result = MagicMock(side_effect=FuturesTimeoutError())
|
||
|
||
loop_mock = MagicMock()
|
||
loop_mock.is_running.return_value = True
|
||
sched.bot.main_event_loop = loop_mock
|
||
|
||
def _run_coro_threadsafe(coro, loop):
|
||
coro.close()
|
||
return future_mock
|
||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_run_coro_threadsafe):
|
||
sched.send_interval_advert()
|
||
|
||
# The error log must include the class name, not just str(e) which
|
||
# would be empty for concurrent.futures.TimeoutError
|
||
call_args_list = mock_logger.error.call_args_list
|
||
assert call_args_list, "logger.error was never called"
|
||
logged = str(call_args_list[0])
|
||
assert "TimeoutError" in logged
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _send_scheduled_message_async (PR2 fix — asyncio.wait_for wrapping)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSendScheduledMessageAsyncTimeout:
|
||
"""Tests for _send_scheduled_message_async() asyncio.wait_for wrapping (PR2)."""
|
||
|
||
def test_success_calls_send_channel_message(self, mock_logger):
|
||
sched = _make_sched_with_logger(mock_logger)
|
||
sched.bot.command_manager.send_channel_message = AsyncMock(return_value=None)
|
||
|
||
asyncio.run(sched._send_scheduled_message_async("#general", "hello"))
|
||
|
||
sched.bot.command_manager.send_channel_message.assert_awaited_once_with(
|
||
"#general", "hello"
|
||
)
|
||
|
||
def test_timeout_raises_asyncio_timeout_error(self, mock_logger):
|
||
sched = _make_sched_with_logger(mock_logger)
|
||
|
||
async def run():
|
||
async def fake_wait_for(coro, timeout):
|
||
raise asyncio.TimeoutError()
|
||
|
||
with patch("asyncio.wait_for", side_effect=fake_wait_for):
|
||
await sched._send_scheduled_message_async("#general", "hello")
|
||
|
||
with pytest.raises(asyncio.TimeoutError):
|
||
asyncio.run(run())
|
||
|
||
def test_send_timeout_seconds_config_used(self, mock_logger):
|
||
"""send_timeout_seconds from config is passed to wait_for."""
|
||
sched = _make_sched_with_logger(mock_logger)
|
||
sched.bot.config.set("Bot", "send_timeout_seconds", "45")
|
||
sched.bot.command_manager.send_channel_message = AsyncMock(return_value=None)
|
||
|
||
captured_timeout = []
|
||
|
||
async def spy_wait_for(coro, timeout):
|
||
captured_timeout.append(timeout)
|
||
return await coro
|
||
|
||
async def run():
|
||
with patch("asyncio.wait_for", side_effect=spy_wait_for):
|
||
await sched._send_scheduled_message_async("#general", "hello")
|
||
|
||
asyncio.run(run())
|
||
assert captured_timeout == [45]
|
||
|
||
def test_send_scheduled_message_logs_exception_type_name(self, mock_logger):
|
||
"""Error log must include type(e).__name__."""
|
||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||
|
||
sched = _make_sched_with_logger(mock_logger)
|
||
|
||
future_mock = MagicMock()
|
||
future_mock.result = MagicMock(side_effect=FuturesTimeoutError())
|
||
|
||
loop_mock = MagicMock()
|
||
loop_mock.is_running.return_value = True
|
||
sched.bot.main_event_loop = loop_mock
|
||
|
||
def _run_coro_threadsafe(coro, loop):
|
||
coro.close()
|
||
return future_mock
|
||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_run_coro_threadsafe):
|
||
sched.send_scheduled_message("#general", "hello")
|
||
|
||
call_args_list = mock_logger.error.call_args_list
|
||
assert call_args_list, "logger.error was never called"
|
||
logged = str(call_args_list[0])
|
||
assert "TimeoutError" in logged
|
||
# ---------------------------------------------------------------------------
|
||
# SSRF guard — SMTP host validation in email-sending methods
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_smtp_scheduler(
|
||
notif_overrides: dict,
|
||
meta_overrides: dict | None = None,
|
||
) -> "MessageScheduler":
|
||
"""Return a scheduler whose maintenance.get_notif returns values from notif_overrides."""
|
||
bot = Mock()
|
||
bot.logger = Mock()
|
||
bot.config = ConfigParser()
|
||
bot.config.add_section("Bot")
|
||
bot.config.set("Bot", "radio_zombie_alert_enabled", "true")
|
||
bot.config.set("Bot", "radio_zombie_alert_email", "")
|
||
bot.config.add_section("Connection")
|
||
bot.config.set("Connection", "connection_type", "serial")
|
||
metadata = dict(meta_overrides or {})
|
||
bot.db_manager = Mock()
|
||
bot.db_manager.get_metadata = Mock(side_effect=lambda key: metadata.get(key, ""))
|
||
sched = MessageScheduler(bot)
|
||
defaults = {
|
||
"nightly_enabled": "true",
|
||
"smtp_host": "smtp.example.com",
|
||
"smtp_port": "587",
|
||
"smtp_security": "starttls",
|
||
"smtp_user": "",
|
||
"smtp_password": "",
|
||
"from_name": "Bot",
|
||
"from_email": "bot@example.com",
|
||
"recipients": "admin@example.com",
|
||
}
|
||
defaults.update(notif_overrides)
|
||
sched.maintenance.get_notif = Mock(side_effect=lambda k: defaults.get(k, ""))
|
||
sched._get_notif = Mock(side_effect=lambda k: defaults.get(k, "")) # zombie alert still uses _get_notif
|
||
return sched
|
||
|
||
|
||
class TestNightlyEmailSsrfGuard:
|
||
"""send_nightly_email must abort on private IP unless allow_local_smtp=true."""
|
||
|
||
@pytest.mark.parametrize("bad_host", [
|
||
"10.0.0.1", # RFC 1918 10.0.0.0/8
|
||
"172.16.0.1", # RFC 1918 172.16.0.0/12
|
||
"192.168.1.1", # RFC 1918 192.168.0.0/16
|
||
"127.0.0.1", # RFC 1122 127.0.0.0/8 loopback
|
||
"169.254.0.1", # RFC 3927 169.254.0.0/16 link-local
|
||
"100.64.0.1", # RFC 6598 100.64.0.0/10 shared/CGN
|
||
"::1", # RFC 4291 ::1/128 IPv6 loopback
|
||
"fd00::1", # RFC 4193 fc00::/7 IPv6 ULA
|
||
"fe80::1", # RFC 4291 fe80::/10 IPv6 link-local
|
||
])
|
||
def test_private_smtp_host_aborts_nightly_email(self, bad_host):
|
||
sched = _make_smtp_scheduler({"smtp_host": bad_host})
|
||
sched.maintenance.send_nightly_email()
|
||
sched.bot.logger.error.assert_called()
|
||
logged = str(sched.bot.logger.error.call_args_list)
|
||
assert "private" in logged.lower() or "reserved" in logged.lower()
|
||
|
||
@pytest.mark.parametrize("local_host", ["127.0.0.1", "192.168.1.1"])
|
||
def test_allow_local_smtp_bypasses_ssrf_guard(self, local_host):
|
||
"""allow_local_smtp=true permits private-IP SMTP (e.g. local Postfix)."""
|
||
sched = _make_smtp_scheduler({"smtp_host": local_host, "allow_local_smtp": "true"})
|
||
# Should not abort at the SSRF guard — will fail at smtplib (connection refused)
|
||
# so error log must NOT contain the SSRF rejection message
|
||
with patch("smtplib.SMTP", side_effect=ConnectionRefusedError("no server")):
|
||
with patch("smtplib.SMTP_SSL", side_effect=ConnectionRefusedError("no server")):
|
||
sched.maintenance.send_nightly_email()
|
||
logged = str(sched.bot.logger.error.call_args_list)
|
||
assert "private" not in logged.lower() and "reserved" not in logged.lower()
|
||
|
||
|
||
class TestZombieAlertEmailSsrfGuard:
|
||
"""send_zombie_alert_email must abort on private IP unless allow_local_smtp=true."""
|
||
|
||
@pytest.mark.parametrize("bad_host", [
|
||
"10.0.0.1", # RFC 1918 10.0.0.0/8
|
||
"172.16.0.1", # RFC 1918 172.16.0.0/12
|
||
"192.168.1.1", # RFC 1918 192.168.0.0/16
|
||
"127.0.0.1", # RFC 1122 127.0.0.0/8 loopback
|
||
"169.254.0.1", # RFC 3927 169.254.0.0/16 link-local
|
||
"100.64.0.1", # RFC 6598 100.64.0.0/10 shared/CGN
|
||
"::1", # RFC 4291 ::1/128 IPv6 loopback
|
||
"fd00::1", # RFC 4193 fc00::/7 IPv6 ULA
|
||
"fe80::1", # RFC 4291 fe80::/10 IPv6 link-local
|
||
])
|
||
def test_private_smtp_host_aborts_zombie_alert(self, bad_host):
|
||
sched = _make_smtp_scheduler({"smtp_host": bad_host})
|
||
sched.send_zombie_alert_email(fail_count=5, threshold=3, interval=60)
|
||
sched.bot.logger.error.assert_called()
|
||
logged = str(sched.bot.logger.error.call_args_list)
|
||
assert "private" in logged.lower() or "reserved" in logged.lower()
|
||
|
||
def test_allow_local_smtp_bypasses_ssrf_guard_zombie_alert(self):
|
||
"""allow_local_smtp=true permits private-IP SMTP for zombie alert."""
|
||
sched = _make_smtp_scheduler({"smtp_host": "127.0.0.1", "allow_local_smtp": "true"})
|
||
with patch("smtplib.SMTP", side_effect=ConnectionRefusedError("no server")):
|
||
with patch("smtplib.SMTP_SSL", side_effect=ConnectionRefusedError("no server")):
|
||
sched.send_zombie_alert_email(fail_count=5, threshold=3, interval=60)
|
||
logged = str(sched.bot.logger.error.call_args_list)
|
||
assert "private" not in logged.lower() and "reserved" not in logged.lower()
|
||
|
||
def test_metadata_alert_enabled_false_suppresses_zombie_alert(self):
|
||
"""zombie.alert_enabled metadata should suppress alert sending immediately."""
|
||
sched = _make_smtp_scheduler(
|
||
{"smtp_host": "smtp.example.com", "from_email": "bot@example.com"},
|
||
{"zombie.alert_enabled": "false"},
|
||
)
|
||
with patch("smtplib.SMTP") as mock_smtp:
|
||
with patch("smtplib.SMTP_SSL") as mock_smtp_ssl:
|
||
sched.send_zombie_alert_email(fail_count=5, threshold=3, interval=60)
|
||
mock_smtp.assert_not_called()
|
||
mock_smtp_ssl.assert_not_called()
|