Files
plm-lottery/tests/unit/test_admin.py
T
davideandClaude Sonnet 5.5 db24287ea5 Make the draw a commit-reveal at a fixed, confirmed, quorum-agreed height with a public proof (B-73, RND-14 … RND-20, RND-33 … RND-37, DAT-61 … DAT-66, API-25, API-45, AUD-24 … AUD-27, AUD-37, ELC-15, SEC-56, ADR-24, ADR-25)
The seed was the hash of the first block seen after the close, so a miner holding tickets
could withhold blocks until one made them win, the active Electrum server could pick which
real block was used by not announcing another (a failed corroboration even moved the draw to
the next block), and the participant order was the server-only `broadcast_at` with no
tie-break, so nobody could recompute the result.

- Open: every round gets a 32-byte `server_seed`, stored only Fernet-encrypted with the
  master-xprv key (`server_seed_enc`), with its published `server_seed_hash`;
  `draw_confirmations` is copied from round_config. The plaintext column is written only by
  the reveal.
- Entering `drawing` (one transaction, `draw_committed` audit row): participants ordered by
  bet txid, each `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n`
  lines; new `bet_vout`), `draw_height = tip_at_close + 1`. A tip of 0 keeps the round
  `closing` (`draw_baseline_tip_unknown`, once per round). All persisted, so a resumed draw
  reads rather than recomputes (the `_tick` branch itself is B-81).
- `_complete_draw` / `_wait_for_draw_block` replace `_wait_for_next_block`: wait for
  `draw_confirmations` blocks from `draw_height`, then `ElectrumListener.agreed_block_hash`
  asks every server, the active one included, for the header at that exact height (valid
  work, >= 2 other answers, strict majority of all); no agreement means the same height is
  asked again and `draw_header_corroboration_failed` is written once per distinct reported
  hash. `draw_stalled` now counts from the last tip advance, or from when the quorum started
  to fail. Seed = sha256(server_seed || block_hash || participants_hash), index = seed mod n.
- The winner, the seed and the reveal (`server_seed`, `winner_drawn` payload) are written in
  one transaction; a round closing with no participants reveals too. A round opened before
  this change (no commitment) is given a secret as it enters `drawing`.
- `GET /rounds/{id}/proof` (public, 404 `round_not_found`, translated in all 7 languages) and
  the new fields on `GET /rounds/current`; `draw_confirmations` in `/admin/config` and the
  /admin form; `server_seed_hash`/`draw_height` in `/admin/rounds`, never the secret.
- Alembic d3e8b15a7c42 (plain add_column / create_index, expression indexes untouched).
- Docs: the B-73 `*Stato:*`/`*Oggi:*` lines are gone, guides and CLAUDE.md describe the new
  draw. Ambiguities resolved in api.md: the proof's participants also carry `amount_sats`
  (RND-37 lists it); `seed` is decimal in the proof and hex in the audit row, as specified.

Tests changed because the spec changed: ElectrumListener.corroborate_header (asked only the
other servers about the block the active one announced) is replaced by agreed_block_hash and
its quorum tests ported; draw_winner takes the secret and the list fingerprint; the four
_wait_for_next_block scheduler tests are replaced by _wait_for_draw_block ones (fixed height,
confirmations, same-height retry, stall clock). New: test_draw_proof.py recomputes the winner
independently from the public proof, checks the plaintext secret is nowhere in the database,
the API or the log before the reveal, and that a second pass over a `drawing` round yields
the same winner. Mutation-checked: ordering by broadcast_at, an early reveal and asking the
tip instead of the draw height each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:29:09 +02:00

419 lines
16 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},
],
)
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])