Files
pyMC_Repeater/tests/test_companion_state_load.py
agessaman c22e4df37d refactor(repeater): consolidate identity collision validation
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.
2026-07-15 00:12:42 -07:00

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 == {}