mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-07-27 03:52:47 +02:00
c22e4df37d
The startup preflight re-implemented the identity collision rules in main.py, reconstructing IdentityManager state by string-parsing its 'type:name' labels, and every configured identity was parsed and constructed twice (once for preflight, once for loading), duplicating config warnings on each start. Move batch validation into IdentityManager.validate_specs(), which checks a batch of IdentitySpec entries against registered identities and against each other without mutating state, and relocate IdentityConfigurationError next to it. The daemon now parses room server and companion configs once during preflight and the identity loaders reuse the cached specs.
203 lines
8.4 KiB
Python
203 lines
8.4 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.identity_manager import IdentityManager
|
|
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_contacts_and_channels_without_preloading_messages(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
|
|
sqlite.companion_load_messages.assert_not_called()
|
|
sqlite.companion_count_messages.assert_not_called()
|
|
bridge.message_queue.push.assert_not_called()
|
|
|
|
@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)
|
|
|
|
|
|
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.identity_manager = IdentityManager({})
|
|
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 = IdentityManager({})
|
|
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 == {}
|