Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto the round when it opened. The cost of a bet and the fee address were read live, so an edit from /admin in the middle of a round gave the players of that round different stakes for the same chance and redirected the commission of the round in progress. - `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column, existing rounds backfilled with the current configuration, i.e. what they were using; the expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the round's fee address (an empty one on the row still stops the payout, as before), `GET /rounds/current` advertises the round's cost while one is active and the configured one otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10). - `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included, has reached the round's cap, before anything moves; the pool can exceed it by one bet at most. A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`. - PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the bet cost the round in progress opened with, which can be lower than the configured one. - `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the seven translations are generic. Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and "applies immediately, also to the round in progress" in the round_config table of modello-dati; the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line). Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and the old "fee address missing" payout test now empties the round's address (clearing it in the configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open, edits reaching only the next round, every bet of a round paying the same stake whether the cost is raised or lowered meanwhile, the payout following the round's address (and surviving it being cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap, a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route, the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the migration backfill. Mutation-checked: charging the live cost, paying the live address, not copying the cap and `>` instead of `>=` each fail tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
563 lines
25 KiB
Python
563 lines
25 KiB
Python
import pytest
|
|
from cryptography.fernet import Fernet
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.config import settings
|
|
|
|
# A real PLM bech32 address: PUT /admin/config now validates fee_address, since a
|
|
# foreign-chain address there would send every round's commission to a script
|
|
# nobody can spend (B-05).
|
|
_VALID_FEE_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"
|
|
|
|
|
|
@pytest.fixture
|
|
async def client(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
|
|
monkeypatch.setattr(settings, "admin_token", "test-admin-token")
|
|
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
|
|
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
|
|
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
|
|
|
|
import app.wallet.hd as hd
|
|
|
|
hd._account_key = None
|
|
hd.generate_master_key()
|
|
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
|
from app.db import base as db_base
|
|
|
|
db_base.engine = create_async_engine(settings.database_url)
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
|
|
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
|
|
|
|
# app.db.session did `from app.db.base import AsyncSessionLocal` at its own
|
|
# first import, which only copies the reference as it was at that moment —
|
|
# reassigning db_base.AsyncSessionLocal above doesn't reach it. get_session()
|
|
# looks up its module global at call time, so rebinding it here (every test)
|
|
# keeps it pointed at *this* test's engine instead of whichever ran first.
|
|
from app.db import session as db_session
|
|
|
|
db_session.AsyncSessionLocal = db_base.AsyncSessionLocal
|
|
|
|
async with db_base.engine.begin() as conn:
|
|
await conn.run_sync(db_base.Base.metadata.create_all)
|
|
|
|
from app.api.routes.admin import router as admin_router
|
|
from fastapi import FastAPI
|
|
|
|
app = FastAPI()
|
|
app.include_router(admin_router)
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
yield ac
|
|
|
|
await db_base.engine.dispose()
|
|
|
|
|
|
async def test_admin_requires_token(client):
|
|
resp = await client.get("/admin/config")
|
|
assert resp.status_code == 403
|
|
|
|
|
|
async def test_admin_rejects_wrong_token(client):
|
|
resp = await client.get("/admin/config", headers={"X-Admin-Token": "wrong"})
|
|
assert resp.status_code == 403
|
|
|
|
|
|
async def test_admin_rejects_non_ascii_token_with_403_not_500(client):
|
|
"""B-46: secrets.compare_digest raises TypeError on a non-ASCII str, which
|
|
used to bubble up as a 500 instead of the expected 403. httpx encodes str
|
|
header values as ASCII client-side, so the raw UTF-8 bytes are passed
|
|
directly to reproduce what a real non-ASCII header on the wire looks like."""
|
|
resp = await client.get("/admin/config", headers={"X-Admin-Token": "café".encode("utf-8")})
|
|
assert resp.status_code == 403
|
|
|
|
|
|
async def test_admin_reads_and_updates_config(client):
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
|
|
resp = await client.get("/admin/config", headers=headers)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["fee_address"] == ""
|
|
|
|
resp = await client.put(
|
|
"/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS, "bet_amount_sats": 500_000_000}
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["fee_address"] == _VALID_FEE_ADDRESS
|
|
assert body["bet_amount_sats"] == 500_000_000
|
|
|
|
resp = await client.get("/admin/config", headers=headers)
|
|
assert resp.json()["fee_address"] == _VALID_FEE_ADDRESS
|
|
|
|
|
|
async def test_admin_can_pause_and_resume_the_lottery(client):
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
|
|
resp = await client.get("/admin/config", headers=headers)
|
|
assert resp.json()["paused"] is False
|
|
|
|
resp = await client.post("/admin/pause", headers=headers)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["paused"] is True
|
|
|
|
resp = await client.get("/admin/config", headers=headers)
|
|
assert resp.json()["paused"] is True
|
|
|
|
resp = await client.post("/admin/resume", headers=headers)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["paused"] is False
|
|
|
|
|
|
async def test_admin_pause_requires_token(client):
|
|
resp = await client.post("/admin/pause")
|
|
assert resp.status_code == 403
|
|
|
|
|
|
async def test_admin_lists_users(client):
|
|
from app.db import base as db_base
|
|
from app.db.models import User
|
|
from app.wallet.hd import derive_user_address
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
session.add(
|
|
User(
|
|
username="alice",
|
|
password_hash="unused",
|
|
derivation_index=0,
|
|
address=derive_user_address(0),
|
|
cached_balance_sats=1_000_000_000,
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.get("/admin/users", headers=headers)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert len(body) == 1
|
|
assert body[0]["username"] == "alice"
|
|
assert body[0]["balance_sats"] == 1_000_000_000
|
|
|
|
|
|
async def test_admin_exports_user_privkey(client):
|
|
from embit.ec import PrivateKey
|
|
|
|
from app.db import base as db_base
|
|
from app.db.models import User
|
|
from app.wallet.hd import derive_user_address, derive_user_key
|
|
from app.wallet.plm_network import PLM_MAINNET
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
user = User(
|
|
username="bob",
|
|
password_hash="unused",
|
|
derivation_index=1,
|
|
address=derive_user_address(1),
|
|
)
|
|
session.add(user)
|
|
await session.commit()
|
|
await session.refresh(user)
|
|
user_id = user.id
|
|
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.get(f"/admin/users/{user_id}/privkey", headers=headers)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
|
|
expected_wif = PrivateKey(derive_user_key(1).secret, compressed=True, network=PLM_MAINNET).wif(
|
|
network=PLM_MAINNET
|
|
)
|
|
assert body["wif"] == expected_wif
|
|
assert body["address"] == derive_user_address(1)
|
|
|
|
|
|
async def test_admin_privkey_404_for_unknown_user(client):
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.get("/admin/users/999/privkey", headers=headers)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
async def test_admin_resets_user_password(client):
|
|
from app.auth.security import hash_password, verify_password
|
|
from app.db import base as db_base
|
|
from app.db.models import User
|
|
from app.wallet.hd import derive_user_address
|
|
|
|
old_hash = hash_password("original-password")
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
user = User(
|
|
username="carol",
|
|
password_hash=old_hash,
|
|
derivation_index=2,
|
|
address=derive_user_address(2),
|
|
)
|
|
session.add(user)
|
|
await session.commit()
|
|
await session.refresh(user)
|
|
user_id = user.id
|
|
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.post(f"/admin/users/{user_id}/reset-password", headers=headers)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["username"] == "carol"
|
|
new_password = body["new_password"]
|
|
assert new_password and new_password != "original-password"
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
refreshed = await session.get(User, user_id)
|
|
assert refreshed.password_hash != old_hash
|
|
assert verify_password(new_password, refreshed.password_hash)
|
|
assert not verify_password("original-password", refreshed.password_hash)
|
|
# B-34: the reset must bump token_version so a session opened before
|
|
# the reset (e.g. an attacker who had the old password) is evicted
|
|
# immediately rather than staying valid until the JWT naturally expires.
|
|
assert refreshed.token_version == 1
|
|
|
|
|
|
async def test_admin_reset_password_requires_token(client):
|
|
resp = await client.post("/admin/users/1/reset-password")
|
|
assert resp.status_code == 403
|
|
|
|
|
|
async def test_admin_reset_password_404_for_unknown_user(client):
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.post("/admin/users/999/reset-password", headers=headers)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
async def test_config_update_is_audit_logged(client):
|
|
"""B-10: /pause and /resume were logged but a config change wasn't, so the most
|
|
sensitive setting in the system — fee_address, where 30% of every pool goes —
|
|
could be changed without leaving any trace."""
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
await client.put("/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS})
|
|
|
|
resp = await client.get("/admin/audit-log", headers=headers)
|
|
entries = [e for e in resp.json() if e["event_type"] == "config_updated"]
|
|
assert len(entries) == 1
|
|
assert entries[0]["payload"]["fee_address"] == {"from": "", "to": _VALID_FEE_ADDRESS}
|
|
|
|
|
|
async def test_config_update_without_changes_logs_nothing(client):
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
await client.put("/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS})
|
|
await client.put("/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS})
|
|
|
|
resp = await client.get("/admin/audit-log", headers=headers)
|
|
assert len([e for e in resp.json() if e["event_type"] == "config_updated"]) == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
{"fee_address": "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"}, # valid bech32, wrong chain
|
|
{"fee_address": "plm1qbogus"}, # right HRP, broken checksum
|
|
{"fee_address": "garbage"},
|
|
{"fee_rate_sat_vb": 0}, # fee-less txs are never relayed: everything would stall
|
|
{"round_duration_seconds": 0}, # a round that expires the instant it opens
|
|
{"bet_amount_sats": -1},
|
|
{"rbf_timeout_seconds": 1},
|
|
{"draw_confirmations": 0}, # B-73: a draw with no confirmations is a draw on the tip
|
|
{"draw_confirmations": 21},
|
|
{"deposit_confirmations": 0}, # B-82: a deposit credited with no confirmation at all
|
|
{"deposit_confirmations": 101},
|
|
],
|
|
)
|
|
async def test_config_rejects_unusable_values(client, payload):
|
|
"""B-05: every one of these was accepted before. The bc1 case is the worst — it
|
|
parses as a valid witness program, so each round's commission would be broadcast
|
|
to a script nobody holds the key for."""
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.put("/admin/config", headers=headers, json=payload)
|
|
assert resp.status_code == 422
|
|
|
|
# and nothing was written
|
|
current = (await client.get("/admin/config", headers=headers)).json()
|
|
for field, value in payload.items():
|
|
assert current[field] != value
|
|
|
|
|
|
async def test_pause_cannot_be_toggled_through_the_config_endpoint(client):
|
|
"""B-10: `paused` used to be settable here, bypassing the audit-logged
|
|
pause/resume endpoints."""
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.put("/admin/config", headers=headers, json={"paused": True})
|
|
assert resp.status_code in (200, 422) # ignored or refused, but never applied
|
|
assert (await client.get("/admin/config", headers=headers)).json()["paused"] is False
|
|
|
|
|
|
@pytest.mark.parametrize("endpoint", ["/admin/rounds", "/admin/audit-log", "/admin/pending-transactions"])
|
|
@pytest.mark.parametrize("bad_limit", [0, -1, 501])
|
|
async def test_admin_list_endpoints_reject_out_of_range_limit(client, endpoint, bad_limit):
|
|
"""B-45: `limit` had no bounds — `-1` means "everything" on SQLite, so an
|
|
unvalidated limit could dump the entire table in one response."""
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.get(endpoint, headers=headers, params={"limit": bad_limit})
|
|
assert resp.status_code == 422
|
|
|
|
|
|
async def test_admin_list_rounds_respects_limit(client):
|
|
from app.db import base as db_base
|
|
from app.db.models import Round
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
session.add_all([Round(status="closed") for _ in range(3)])
|
|
await session.commit()
|
|
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.get("/admin/rounds", headers=headers, params={"limit": 2})
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()) == 2
|
|
|
|
|
|
async def test_admin_audit_log_respects_limit(client):
|
|
from app.db import base as db_base
|
|
from app.audit.log import write_audit_log
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
for _ in range(3):
|
|
await write_audit_log(session, "test_event", {})
|
|
await session.commit()
|
|
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.get("/admin/audit-log", headers=headers, params={"limit": 2})
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()) == 2
|
|
|
|
|
|
async def _make_pending_transaction(session, *, kind="bet", status="pending"):
|
|
from app.db.models import PendingTransaction
|
|
import secrets as _secrets
|
|
|
|
tx = PendingTransaction(
|
|
kind=kind,
|
|
current_txid=_secrets.token_hex(32),
|
|
fee_rate_sat_vb=1,
|
|
raw_tx_hex="00",
|
|
status=status,
|
|
)
|
|
session.add(tx)
|
|
return tx
|
|
|
|
|
|
async def test_admin_pending_transactions_respects_limit(client):
|
|
from app.db import base as db_base
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
for _ in range(3):
|
|
await _make_pending_transaction(session)
|
|
await session.commit()
|
|
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.get("/admin/pending-transactions", headers=headers, params={"limit": 2})
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()) == 2
|
|
|
|
|
|
async def test_admin_pending_transactions_status_filter(client):
|
|
from app.db import base as db_base
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
await _make_pending_transaction(session, status="pending")
|
|
await _make_pending_transaction(session, status="confirmed")
|
|
await _make_pending_transaction(session, status="failed")
|
|
await session.commit()
|
|
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.get(
|
|
"/admin/pending-transactions", headers=headers, params={"status": "confirmed"}
|
|
)
|
|
assert resp.status_code == 200
|
|
entries = resp.json()
|
|
assert len(entries) == 1
|
|
assert entries[0]["status"] == "confirmed"
|
|
|
|
|
|
async def test_admin_list_rounds_shows_each_rounds_pool_address(client):
|
|
"""API-36 / B-80: pool_address per round; null for a row without one."""
|
|
from app.db import base as db_base
|
|
from app.db.models import Round
|
|
from app.wallet.hd import derive_pool_address
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
session.add(Round(id=1, status="closed", pool_address=derive_pool_address(1)))
|
|
session.add(Round(id=2, status="closed"))
|
|
await session.commit()
|
|
|
|
resp = await client.get("/admin/rounds", headers={"X-Admin-Token": "test-admin-token"})
|
|
by_id = {row["id"]: row for row in resp.json()}
|
|
assert by_id[1]["pool_address"] == derive_pool_address(1)
|
|
assert by_id[2]["pool_address"] is None
|
|
|
|
|
|
async def test_draw_confirmations_default_to_3_and_are_editable_within_1_to_20(client):
|
|
"""B-73 (DAT-62, API-30, API-31): the number of blocks the draw waits for on its block."""
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
assert (await client.get("/admin/config", headers=headers)).json()["draw_confirmations"] == 3
|
|
|
|
resp = await client.put("/admin/config", headers=headers, json={"draw_confirmations": 6})
|
|
assert resp.status_code == 200 and resp.json()["draw_confirmations"] == 6
|
|
assert (await client.put("/admin/config", headers=headers, json={"draw_confirmations": 1})).status_code == 200
|
|
assert (await client.put("/admin/config", headers=headers, json={"draw_confirmations": 20})).status_code == 200
|
|
|
|
|
|
async def test_admin_round_list_shows_the_commitment_but_never_the_secret(client):
|
|
"""B-73 (API-36): server_seed_hash and draw_height are listed, server_seed is not."""
|
|
from app.db import base as db_base
|
|
from app.db.models import Round
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
session.add(Round(status="closed", server_seed_hash="ab" * 32, server_seed="cd" * 32, draw_height=77))
|
|
await session.commit()
|
|
rounds = (await client.get("/admin/rounds", headers={"X-Admin-Token": "test-admin-token"})).json()
|
|
assert rounds[0]["server_seed_hash"] == "ab" * 32 and rounds[0]["draw_height"] == 77
|
|
assert "server_seed" not in rounds[0] and "cd" * 32 not in str(rounds[0])
|
|
|
|
|
|
# --- B-79 (RND-40, API-31): a bet amount or fee rate that would make a round unpayable ---
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
{"bet_amount_sats": 5_000}, # the platform's 30% cannot cover the reserve and dust
|
|
{"bet_amount_sats": 3_000}, # does not even pay for its own transaction
|
|
],
|
|
)
|
|
async def test_config_refuses_a_change_that_would_make_a_round_unpayable(client, payload):
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
before = (await client.get("/admin/config", headers=headers)).json()
|
|
resp = await client.put("/admin/config", headers=headers, json=payload)
|
|
assert resp.status_code == 422
|
|
detail = resp.json()["detail"]
|
|
assert detail["code"] == "config_not_payable" and detail["params"]["reason"]
|
|
assert (await client.get("/admin/config", headers=headers)).json() == before # nothing written
|
|
audit = (await client.get("/admin/audit-log", headers=headers)).json()
|
|
assert not [e for e in audit if e["event_type"] == "config_updated"]
|
|
|
|
|
|
async def test_a_fee_rate_raised_mid_round_cannot_make_the_round_unpayable(client):
|
|
"""The payout reads fee_rate_sat_vb when it is built, so the rate is the one live value
|
|
that can still break a round in progress — it is bounded by the same check, judged
|
|
together with the stored bet amount (and with a bet amount sent in the same request)."""
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
|
|
assert (await put({"bet_amount_sats": 50_000})).status_code == 200
|
|
assert (await put({"fee_rate_sat_vb": 10})).status_code == 200 # still payable: 1 bet nets 15_530
|
|
resp = await put({"fee_rate_sat_vb": 14}) # the 70% no longer covers the payout's network fee
|
|
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "config_not_payable"
|
|
assert (await client.get("/admin/config", headers=headers)).json()["fee_rate_sat_vb"] == 10
|
|
# merged with the same request: a larger bet makes the higher rate payable
|
|
resp = await put({"bet_amount_sats": 1_000_000, "fee_rate_sat_vb": 20})
|
|
assert resp.status_code == 200
|
|
# ... and the other way round, a bet that is fine alone is refused at the stored rate
|
|
assert (await put({"bet_amount_sats": 50_000})).status_code == 422
|
|
|
|
|
|
async def test_config_still_accepts_a_payable_change_and_unrelated_edits(client):
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
resp = await client.put(
|
|
"/admin/config", headers=headers, json={"bet_amount_sats": 100_000, "fee_rate_sat_vb": 2, "rbf_timeout_seconds": 120}
|
|
)
|
|
assert resp.status_code == 200
|
|
# an unrelated edit is never blocked by the payability check
|
|
assert (await client.put("/admin/config", headers=headers, json={"draw_animation_seconds": 5})).status_code == 200
|
|
|
|
|
|
async def test_deposit_confirmations_is_configurable_between_1_and_100(client): # B-82 (DAT-62, API-31)
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
assert (await client.get("/admin/config", headers=headers)).json()["deposit_confirmations"] == 6
|
|
for value in (1, 100, 12):
|
|
resp = await client.put("/admin/config", headers=headers, json={"deposit_confirmations": value})
|
|
assert resp.status_code == 200 and resp.json()["deposit_confirmations"] == value
|
|
audit = (await client.get("/admin/audit-log", headers=headers)).json()
|
|
changes = [e["payload"]["deposit_confirmations"] for e in audit if e["event_type"] == "config_updated"]
|
|
assert {"from": 100, "to": 12} in changes
|
|
|
|
|
|
async def test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain(client): # B-82 (DAT-68, API-32)
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.db import base as db_base
|
|
from app.db.models import AuditLog, Round
|
|
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
async with db_base.engine.begin() as conn: # the fixture creates the tables before the models are imported
|
|
await conn.run_sync(db_base.Base.metadata.create_all)
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
session.add(Round(status="paying_out", payout_blocked_at=datetime.now(timezone.utc)))
|
|
session.add(Round(status="closed"))
|
|
await session.commit()
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
from app.rounds.pause import pause_automatically
|
|
|
|
await pause_automatically(session, "payout_blocked")
|
|
await session.commit()
|
|
assert (await client.get("/admin/config", headers=headers)).json()["paused_reason"] == "payout_blocked"
|
|
listed = (await client.get("/admin/rounds", headers=headers)).json() # API-36: the mark is visible
|
|
assert sorted(r["payout_blocked_at"] is not None for r in listed) == [False, True]
|
|
|
|
resp = await client.post("/admin/resume", headers=headers)
|
|
assert resp.status_code == 200 and resp.json()["paused"] is False and resp.json()["paused_reason"] is None
|
|
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
assert (await session.scalars(select(Round.payout_blocked_at))).all() == [None, None]
|
|
resumed = [e for e in (await session.scalars(select(AuditLog))).all() if e.event_type == "lottery_resumed"]
|
|
assert resumed and '"payout_blocked"' in resumed[0].payload_json
|
|
|
|
|
|
# --- B-95 (DAT-62, API-31): the cap on a round's pool ---------------------------------------------------------------
|
|
|
|
|
|
async def test_max_round_pool_sats_is_configurable_and_bounded(client):
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
assert (await client.get("/admin/config", headers=headers)).json()["max_round_pool_sats"] == 0
|
|
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
|
|
assert (await put({"max_round_pool_sats": 50_000_000_000})).json()["max_round_pool_sats"] == 50_000_000_000
|
|
assert (await put({"max_round_pool_sats": 0})).json()["max_round_pool_sats"] == 0 # 0 switches it off
|
|
assert (await put({"max_round_pool_sats": 4_000_000_000_000_000})).status_code == 200
|
|
assert (await put({"max_round_pool_sats": 4_000_000_000_000_001})).status_code == 422
|
|
assert (await put({"max_round_pool_sats": -1})).status_code == 422
|
|
|
|
|
|
async def test_a_cap_below_the_price_of_one_bet_is_refused_whichever_field_makes_it(client):
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
|
|
resp = await put({"max_round_pool_sats": 999_999_999}) # the bet costs 1_000_000_000
|
|
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "max_round_pool_below_bet_amount"
|
|
assert (await put({"max_round_pool_sats": 1_000_000_000})).status_code == 200 # exactly one bet fits
|
|
resp = await put({"bet_amount_sats": 2_000_000_000}) # now the bet would exceed the stored cap
|
|
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "max_round_pool_below_bet_amount"
|
|
assert (await put({"bet_amount_sats": 2_000_000_000, "max_round_pool_sats": 6_000_000_000})).status_code == 200
|
|
assert (await client.get("/admin/config", headers=headers)).json()["bet_amount_sats"] == 2_000_000_000
|
|
|
|
|
|
async def test_a_fee_rate_is_checked_against_the_bet_amount_of_the_round_in_progress(client):
|
|
"""B-95 with RND-40: the round in progress is paid with the cost it opened with, which can be
|
|
lower than the one configured now, so the fee rate has to leave that one payable too."""
|
|
from datetime import datetime, timezone
|
|
|
|
from app.db import base as db_base
|
|
from app.db.models import Round
|
|
|
|
headers = {"X-Admin-Token": "test-admin-token"}
|
|
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
|
|
async with db_base.engine.begin() as conn: # the fixture creates the tables before the models are imported
|
|
await conn.run_sync(db_base.Base.metadata.create_all)
|
|
assert (await put({"bet_amount_sats": 100_000_000})).status_code == 200
|
|
async with db_base.AsyncSessionLocal() as session:
|
|
session.add(Round(status="open", opened_at=datetime.now(timezone.utc), bet_amount_sats=50_000)) # opened cheap
|
|
await session.commit()
|
|
# 14 sat/vB is fine for the configured 1 PLM (and for 100 PLM) but not for the 50_000 sat round
|
|
resp = await put({"fee_rate_sat_vb": 14})
|
|
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "config_not_payable"
|
|
assert (await put({"fee_rate_sat_vb": 10})).status_code == 200
|