Files

133 lines
5.7 KiB
Python
Raw Permalink Normal View History

"""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"]