133 lines
5.7 KiB
Python
133 lines
5.7 KiB
Python
"""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"]
|