mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-06 17:03:32 +02:00
3a29061e24
Installed openhop_core releases predating the sender_prefix change
(paired with fd43d86) reject the kwarg, so restoring queued messages
raised TypeError and aborted companion init. Detect support via the
QueuedMessage signature and drop persisted prefixes with a warning on
older cores instead of failing the boot.
240 lines
9.8 KiB
Python
240 lines
9.8 KiB
Python
"""Companion boot-path hardening: a failed SQLite load must not present as "no data".
|
|
|
|
Covers _load_companion_rows_verified retry/verification, _restore_companion_state,
|
|
and _load_companion_identities failing companion init loudly when persisted rows
|
|
exist but cannot be loaded (instead of booting an empty store and backfilling
|
|
the Public channel over it).
|
|
"""
|
|
|
|
import logging
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
import repeater.main as main_module
|
|
from repeater.companion.utils import CompanionStateLoadError
|
|
from repeater.main import RepeaterDaemon, _load_companion_rows_verified
|
|
|
|
_HASH = "0xab"
|
|
_NAME = "comp-test"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_retry_delay(monkeypatch):
|
|
monkeypatch.setattr(main_module, "_COMPANION_LOAD_RETRY_DELAY_SEC", 0)
|
|
|
|
|
|
class TestLoadCompanionRowsVerified:
|
|
@pytest.mark.asyncio
|
|
async def test_genuinely_empty_returns_without_retry(self):
|
|
loader = MagicMock(return_value=[])
|
|
counter = MagicMock(return_value=0)
|
|
rows, stored = await _load_companion_rows_verified(loader, counter, "channels", _HASH, _NAME)
|
|
assert rows == []
|
|
assert stored == 0
|
|
assert loader.call_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transient_failure_recovers_on_retry(self, caplog):
|
|
good_rows = [{"channel_idx": 0, "name": "Public", "secret": b"x"}]
|
|
loader = MagicMock(side_effect=[None, good_rows])
|
|
counter = MagicMock(return_value=1)
|
|
with caplog.at_level(logging.WARNING):
|
|
rows, stored = await _load_companion_rows_verified(
|
|
loader, counter, "channels", _HASH, _NAME
|
|
)
|
|
assert rows == good_rows
|
|
assert stored == 1
|
|
assert loader.call_count == 2
|
|
assert any("retrying once" in r.message for r in caplog.records)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persistent_failure_raises(self):
|
|
loader = MagicMock(return_value=None)
|
|
counter = MagicMock(return_value=3)
|
|
with pytest.raises(CompanionStateLoadError, match="channels"):
|
|
await _load_companion_rows_verified(loader, counter, "channels", _HASH, _NAME)
|
|
assert loader.call_count == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_result_with_stored_rows_raises(self):
|
|
# Load "succeeds" with [] while the table has rows for this hash:
|
|
# treat as a failed load, not as no data.
|
|
loader = MagicMock(return_value=[])
|
|
counter = MagicMock(return_value=5)
|
|
with pytest.raises(CompanionStateLoadError, match="5 row"):
|
|
await _load_companion_rows_verified(loader, counter, "contacts", _HASH, _NAME)
|
|
assert loader.call_count == 2
|
|
|
|
|
|
class TestRestoreCompanionState:
|
|
@staticmethod
|
|
def _bridge(max_size=100):
|
|
bridge = MagicMock()
|
|
bridge.message_queue.max_size = max_size
|
|
bridge.channels.set.return_value = True
|
|
return bridge
|
|
|
|
@staticmethod
|
|
def _sqlite(contacts=(), channels=(), messages=()):
|
|
sqlite = MagicMock()
|
|
sqlite.companion_load_contacts.return_value = list(contacts)
|
|
sqlite.companion_count_contacts.return_value = len(contacts)
|
|
sqlite.companion_load_channels.return_value = list(channels)
|
|
sqlite.companion_count_channels.return_value = len(channels)
|
|
sqlite.companion_load_messages.return_value = list(messages)
|
|
sqlite.companion_count_messages.return_value = len(messages)
|
|
return sqlite
|
|
|
|
@staticmethod
|
|
def _daemon():
|
|
return RepeaterDaemon({"repeater": {"node_name": "n"}, "logging": {}}, radio=object())
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restores_all_state(self):
|
|
daemon = self._daemon()
|
|
bridge = self._bridge()
|
|
sqlite = self._sqlite(
|
|
contacts=[{"pubkey": b"\x01" * 32, "name": "c1"}],
|
|
channels=[{"channel_idx": 1, "name": "ch1", "secret": b"\x02" * 32}],
|
|
messages=[{"sender_key": b"", "text": "hi", "sender_prefix": b""}],
|
|
)
|
|
await daemon._restore_companion_state(sqlite, bridge, _HASH, _NAME)
|
|
bridge.contacts.load_from_dicts.assert_called_once()
|
|
bridge.channels.set.assert_called_once()
|
|
assert bridge.channels.set.call_args[0][0] == 1
|
|
bridge.message_queue.push.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_load_failure_raises_before_bridge_touch(self):
|
|
daemon = self._daemon()
|
|
bridge = self._bridge()
|
|
sqlite = self._sqlite()
|
|
sqlite.companion_load_channels.return_value = None
|
|
sqlite.companion_count_channels.return_value = 2
|
|
with pytest.raises(CompanionStateLoadError):
|
|
await daemon._restore_companion_state(sqlite, bridge, _HASH, _NAME)
|
|
bridge.channels.set.assert_not_called()
|
|
bridge.message_queue.push.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejected_channel_set_logs_error(self, caplog):
|
|
daemon = self._daemon()
|
|
bridge = self._bridge()
|
|
bridge.channels.set.return_value = False
|
|
sqlite = self._sqlite(channels=[{"channel_idx": 99, "name": "bad", "secret": b""}])
|
|
with caplog.at_level(logging.ERROR):
|
|
await daemon._restore_companion_state(sqlite, bridge, _HASH, _NAME)
|
|
assert any("rejected persisted channel" in r.message for r in caplog.records)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_old_core_without_sender_prefix_drops_prefix(self, caplog):
|
|
# openhop_core releases before the sender_prefix change reject the kwarg;
|
|
# message restore must degrade (drop the prefix) instead of failing init.
|
|
from dataclasses import dataclass, field
|
|
|
|
@dataclass
|
|
class LegacyQueuedMessage:
|
|
sender_key: bytes = b""
|
|
txt_type: int = 0
|
|
timestamp: int = 0
|
|
text: str = ""
|
|
is_channel: bool = False
|
|
channel_idx: int = 0
|
|
path_len: int = 0
|
|
snr: float = 0.0
|
|
rssi: int = 0
|
|
channel_data_payload: bytes = field(default=b"")
|
|
|
|
daemon = self._daemon()
|
|
bridge = self._bridge()
|
|
sqlite = self._sqlite(
|
|
messages=[{"sender_key": b"", "text": "hi", "sender_prefix": b"\xab\xcd"}]
|
|
)
|
|
with (
|
|
patch("openhop_core.companion.models.QueuedMessage", LegacyQueuedMessage),
|
|
caplog.at_level(logging.WARNING),
|
|
):
|
|
await daemon._restore_companion_state(sqlite, bridge, _HASH, _NAME)
|
|
bridge.message_queue.push.assert_called_once()
|
|
pushed = bridge.message_queue.push.call_args[0][0]
|
|
assert isinstance(pushed, LegacyQueuedMessage)
|
|
assert pushed.text == "hi"
|
|
assert any("sender prefixes will be dropped" in r.message for r in caplog.records)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_zero_retention_skips_message_load(self):
|
|
daemon = self._daemon()
|
|
bridge = self._bridge(max_size=0)
|
|
sqlite = self._sqlite()
|
|
await daemon._restore_companion_state(sqlite, bridge, _HASH, _NAME)
|
|
sqlite.companion_load_messages.assert_not_called()
|
|
|
|
|
|
class TestCompanionInitSurfacesLoadFailure:
|
|
@staticmethod
|
|
def _daemon_with_companion(sqlite):
|
|
config = {
|
|
"repeater": {"node_name": "n"},
|
|
"logging": {},
|
|
"identities": {
|
|
"companions": [
|
|
{"name": _NAME, "identity_key": "11" * 32, "settings": {"tcp_port": 5001}}
|
|
]
|
|
},
|
|
}
|
|
daemon = RepeaterDaemon(config, radio=object())
|
|
daemon.router = SimpleNamespace(inject_packet=AsyncMock())
|
|
daemon.repeater_handler = SimpleNamespace(
|
|
storage=SimpleNamespace(sqlite_handler=sqlite), radio_config={}
|
|
)
|
|
return daemon
|
|
|
|
@staticmethod
|
|
def _failing_sqlite():
|
|
sqlite = MagicMock()
|
|
sqlite.companion_count_contacts.return_value = 0
|
|
sqlite.companion_load_contacts.return_value = []
|
|
# Channels table has rows for this companion but every load fails.
|
|
sqlite.companion_load_channels.return_value = None
|
|
sqlite.companion_count_channels.return_value = 3
|
|
return sqlite
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_companion_identities_aborts_companion(self, caplog):
|
|
sqlite = self._failing_sqlite()
|
|
daemon = self._daemon_with_companion(sqlite)
|
|
with (
|
|
patch("repeater.companion.RepeaterCompanionBridge") as bridge_cls,
|
|
patch("repeater.companion.CompanionFrameServer") as server_cls,
|
|
caplog.at_level(logging.ERROR),
|
|
):
|
|
bridge_cls.return_value.message_queue.max_size = 100
|
|
await daemon._load_companion_identities()
|
|
|
|
# Companion init failed loudly: nothing registered, no frame server,
|
|
# and no Public-channel backfill over the unloaded store.
|
|
assert daemon.companion_bridges == {}
|
|
assert daemon.companion_frame_servers == []
|
|
server_cls.assert_not_called()
|
|
bridge_cls.return_value.set_channel.assert_not_called()
|
|
assert sqlite.companion_load_channels.call_count == 2 # retried once
|
|
assert any("Companion init aborted" in r.message for r in caplog.records)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_companion_from_config_raises(self):
|
|
sqlite = self._failing_sqlite()
|
|
daemon = self._daemon_with_companion(sqlite)
|
|
daemon.identity_manager = SimpleNamespace(named_identities={})
|
|
comp_config = {"name": "hot-comp", "identity_key": "22" * 32, "settings": {}}
|
|
with (
|
|
patch("repeater.companion.RepeaterCompanionBridge") as bridge_cls,
|
|
patch("repeater.companion.CompanionFrameServer") as server_cls,
|
|
):
|
|
bridge_cls.return_value.message_queue.max_size = 100
|
|
with pytest.raises(CompanionStateLoadError):
|
|
await daemon.add_companion_from_config(comp_config)
|
|
server_cls.assert_not_called()
|
|
assert daemon.companion_bridges == {}
|