"""B-90 (REG-15, OPS-27): a restored backup must not hand out derivation indexes that already have history, and START_PAUSED starts the lottery paused.""" import json import pytest from sqlalchemy import select from app.db.models import AuditLog, RoundConfig, User from app.electrum.listener import ElectrumListener from app.electrum.scripthash import address_to_scripthash from app.wallet.hd import derive_user_address from tests.unit.test_electrum_listener import _ENDPOINTS, session_factory # noqa: F401 (fixture) class _Server: """Answers get_history: the given scripthashes have a transaction, the rest none.""" def __init__(self, used=(), fail=False): self.used = set(used) self.fail = fail async def connect(self): if self.fail: raise ConnectionRefusedError("down") async def request(self, method, params=None): assert method == "blockchain.scripthash.get_history" return [{"tx_hash": "aa" * 32, "height": 5}] if params[0] in self.used else [] async def close(self): pass def _listener(session_factory, active, others): servers = {"second.example": others[0], "third.example": others[1]} listener = ElectrumListener(lambda endpoint: servers[endpoint.host], session_factory, _ENDPOINTS) listener.client = active listener.registration_open = False return listener async def _users(session_factory, count): async with session_factory() as session: for i in range(count): session.add(User(username=f"u{i}", password_hash="x", derivation_index=i, address=derive_user_address(i))) await session.commit() def _hash(index): return address_to_scripthash(derive_user_address(index)) async def _audit(session_factory): async with session_factory() as session: return list((await session.execute(select(AuditLog).where(AuditLog.event_type == "restore_gap_detected"))).scalars()) async def test_registration_opens_when_the_next_20_indexes_are_unused(session_factory): await _users(session_factory, 3) listener = _listener(session_factory, _Server(), [_Server(), _Server()]) await listener._check_restore_gap() assert listener.registration_open and await _audit(session_factory) == [] async def test_one_server_reporting_history_is_enough_to_keep_it_closed(session_factory): """REG-15: the checked window is highest + 1 … highest + 20; any server's word counts.""" await _users(session_factory, 3) # highest assigned index 2 -> checks 3..22 listener = _listener(session_factory, _Server(), [_Server(used={_hash(22)}), _Server()]) await listener._check_restore_gap() assert not listener.registration_open (row,) = await _audit(session_factory) payload = json.loads(row.payload_json) assert payload["highest_assigned_index"] == 2 assert payload["used"] == [{"derivation_index": 22, "address": derive_user_address(22)}] async def test_an_index_just_past_the_window_is_not_looked_at(session_factory): await _users(session_factory, 3) listener = _listener(session_factory, _Server(used={_hash(23)}), [_Server(), _Server()]) await listener._check_restore_gap() assert listener.registration_open async def test_an_empty_database_checks_indexes_0_to_19(session_factory): listener = _listener(session_factory, _Server(used={_hash(0)}), [_Server(), _Server()]) await listener._check_restore_gap() (row,) = await _audit(session_factory) assert json.loads(row.payload_json)["highest_assigned_index"] is None async def test_without_the_active_servers_answer_nothing_is_decided(session_factory): """The check is simply tried again on the next session; registration stays closed.""" listener = _listener(session_factory, None, [_Server(), _Server()]) with pytest.raises(RuntimeError): await listener._check_restore_gap() assert not listener.registration_open and not listener._restore_checked async def test_an_unreachable_quorum_server_does_not_block_a_clean_result(session_factory): listener = _listener(session_factory, _Server(), [_Server(fail=True), _Server()]) await listener._check_restore_gap() assert listener.registration_open async def test_register_is_refused_while_the_check_has_not_passed(session_factory, monkeypatch): from httpx import ASGITransport, AsyncClient from app.main import app listener = ElectrumListener(lambda endpoint: None, session_factory, _ENDPOINTS) monkeypatch.setattr(ElectrumListener, "registration_open", False) app.state.electrum_listener = listener async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as client: response = await client.post("/auth/register", json={"username": "alice", "password": "password123"}) assert response.status_code == 503 assert response.json()["detail"]["code"] == "registration_unavailable" async def test_start_paused_pauses_as_the_operator_without_overwriting_an_automatic_reason(session_factory): from app.rounds.pause import pause_automatically, pause_by_operator async with session_factory() as session: await pause_by_operator(session, "START_PAUSED") await session.commit() config = (await session.execute(select(RoundConfig))).scalar_one() assert config.paused and config.paused_reason == "operator" await pause_automatically(session, "reorg") await pause_by_operator(session, "START_PAUSED") await session.commit() assert config.paused_reason == "reorg" # an automatic reason is the one that needs a look events = (await session.execute(select(AuditLog).where(AuditLog.event_type == "lottery_paused"))).scalars().all() assert [json.loads(e.payload_json)["reason"] for e in events] == ["operator", "reorg", "operator"]