Files
pyMC_Repeater/tests/test_identity_collision_preflight.py
T
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

114 lines
4.0 KiB
Python

import logging
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from repeater.identity_manager import IdentityManager
from repeater.main import IdentityConfigurationError, RepeaterDaemon
class _SeedFirstByteIdentity:
"""Deterministically expose a configured key's first byte as its hash."""
def __init__(self, seed: bytes):
self._public_key = bytes([seed[0]]) + b"P" * 31
def get_public_key(self):
return self._public_key
def get_address_bytes(self):
return self._public_key[:3]
def _config(*, companions=()):
return {
"repeater": {"node_name": "n", "identity_key": b"\x10" * 32},
"logging": {},
"identities": {"companions": list(companions)},
}
def test_startup_preflight_rejects_default_repeater_hash_collision():
daemon = RepeaterDaemon(
_config(companions=({"name": "comp", "identity_key": "10" * 32},)),
radio=object(),
)
local_identity = _SeedFirstByteIdentity(b"\x10" * 32)
with patch("openhop_core.LocalIdentity", _SeedFirstByteIdentity):
with pytest.raises(IdentityConfigurationError, match="one-byte public-key prefixes"):
daemon._preflight_configured_local_identities(local_identity)
@pytest.mark.asyncio
async def test_companion_set_collision_is_rejected_before_bridge_or_server_creation():
daemon = RepeaterDaemon(
_config(
companions=(
{"name": "first", "identity_key": "21" * 32},
{"name": "second", "identity_key": "21" * 32},
)
),
radio=object(),
)
daemon.identity_manager = IdentityManager({})
daemon.repeater_handler = SimpleNamespace(storage=SimpleNamespace(sqlite_handler=object()))
with (
patch("openhop_core.LocalIdentity", _SeedFirstByteIdentity),
patch("repeater.companion.RepeaterCompanionBridge") as bridge_cls,
patch("repeater.companion.CompanionFrameServer") as server_cls,
pytest.raises(IdentityConfigurationError, match="second"),
):
await daemon._load_companion_identities()
bridge_cls.assert_not_called()
server_cls.assert_not_called()
assert daemon.companion_bridges == {}
assert daemon.companion_frame_servers == []
@pytest.mark.asyncio
async def test_invalid_config_entry_logs_once_across_preflight_and_load(caplog):
"""Preflight parses the config once and the loaders reuse the cached
specs, so an invalid entry produces exactly one error per startup."""
daemon = RepeaterDaemon(
_config(companions=({"name": "bad", "identity_key": "not-hex"},)),
radio=object(),
)
daemon.identity_manager = IdentityManager({})
local_identity = _SeedFirstByteIdentity(b"\x10" * 32)
with (
patch("openhop_core.LocalIdentity", _SeedFirstByteIdentity),
caplog.at_level(logging.ERROR, logger="RepeaterDaemon"),
):
daemon._preflight_configured_local_identities(local_identity)
await daemon._load_companion_identities()
invalid_key_logs = [r for r in caplog.records if "invalid hex" in r.getMessage()]
assert len(invalid_key_logs) == 1
@pytest.mark.asyncio
async def test_hot_added_companion_collision_is_rejected_before_stateful_setup():
daemon = RepeaterDaemon(_config(), radio=object())
daemon.identity_manager = IdentityManager({})
daemon.identity_manager.register_identity(
"repeater", _SeedFirstByteIdentity(b"\x33" * 32), {}, "repeater"
)
comp_config = {"name": "comp", "identity_key": "33" * 32, "settings": {}}
with (
patch("openhop_core.LocalIdentity", _SeedFirstByteIdentity),
patch("repeater.companion.RepeaterCompanionBridge") as bridge_cls,
patch("repeater.companion.CompanionFrameServer") as server_cls,
pytest.raises(ValueError, match="Cannot add companion"),
):
await daemon.add_companion_from_config(comp_config)
bridge_cls.assert_not_called()
server_cls.assert_not_called()
assert daemon.companion_bridges == {}