Files
plm-lottery/tests/unit/test_users.py
T
davideandClaude Sonnet 5.5 54dbffb552 Credit deposits at deposit_confirmations and verify the round's coins with the quorum before paying (B-82, DEP-03, DEP-04, DEP-09, DEP-12, DEP-14, ELC-17, ELC-27, RND-21, RND-22, RND-25, RND-39, DAT-10, DAT-11, DAT-24, DAT-62, DAT-67, DAT-68, AUD-12, AUD-44, AUD-45, API-30, API-31, API-36, ADR-06, ADR-52)
A user could bet and then double-spend the 1-conference deposit that funded the bet with a
private chain: the bet left the chain but its participant stayed `confirmed`, and the payout
failed "insufficient pool UTXOs" for ever with everyone else's money stuck. Coinbase outputs
were also credited at 1 confirmation (maturity is 120).

Deposits
- `round_config.deposit_confirmations` (default 6, 1-100, editable from /admin) is the
  threshold for a deposit; an output of a platform transaction (any txid of a
  pending_transactions row, RBF versions included) keeps 1; a coinbase output needs 120,
  recognised from the raw transaction, which must hash to the txid (`is_coinbase`, cached).
  `read_deposit_policy` / `classify_new_deposits` run between the read and the write, so no
  session is open across the lookup.
- The quorum must also place the coin deep enough on our own tip (ELC-17).
- Confirmed outputs short of their threshold are pending deposits (memory only, replaced at
  every refresh): shown in `pending_balance_sats` / `has_pending` (also for DEP-13's withdrawal
  message), never spendable. No scripthash notification announces a maturing deposit, so each
  new block refreshes only the users that have one, one task per user (DEP-09 now says so).

Payout
- `_trigger_payout` verifies the round's confirmed participants' bet outputs with the other
  servers (`verify_pool_outpoints`: strict majority of >= 2 answers per outpoint, a
  mempool-only coin or a tie gives no verdict) and builds from exactly those outpoints
  (RND-22), never from whatever else sits at the address. No quorum: `payout_failed`, `pool
  verification unavailable`, nothing changes. Bets that are gone become `reorged_out`, one
  `participant_reorged_out` row each, `pool_amount_sats` rewritten (only downward) in the same
  transaction, and the 70/30 split is computed on what is left.
- Winner's own bet gone: nothing is built; `rounds.payout_blocked_at` (Alembic f2c64a8d1e93,
  with `deposit_confirmations`, plain add_column) is set, `payout_blocked` and the pause with
  `paused_reason = 'payout_blocked'` are written in one transaction; `POST /admin/resume` clears
  the mark and the next attempt verifies again. /admin/rounds returns it.
- Rounds opened before B-80 (pool = the shared reserve address) keep the old active-server
  `listunspent` choice, since that address holds no single round's bets.

Ambiguities resolved: the winner's row stays `confirmed` while held (AUD-45 says every
participant whose bet is gone becomes `reorged_out`, but OPS-30/DAT-68 need the verification to
repeat after the resume, which only a `confirmed` row allows); `/`'s deposit hint no longer says
"1 confirmation" (7 languages, same keys); guides updated.

Tests changed because the spec changed: listener tests that credited height-100 coins at tip 0
now set a tip past every threshold; the payout fixtures seed confirmed participants (the new
source of the inputs) instead of an active-server `listunspent`, FakeNetwork answers the
verification, and test_round_pool_address asserts the quorum is asked about the round's
address. New: thresholds at exactly 6/5, configured value, coinbase at 119/120 and a mismatching
or missing raw tx, platform outputs at 1, dust, quorum height, waiting-deposit display and
disappearance, block-triggered refresh, resume, verification verdicts, gone bets excluded with
the split recomputed, winner gone held and paused, held payouts untouched until the resume,
re-verification after it, no quorum, a payout with 1 and with 400 participants against
reserve+fee+dust, admin config bounds and migration. Mutation-checked: threshold 1, ignored
coinbase, no height bound, no verification and no winner hold each fail tests.

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

192 lines
7.4 KiB
Python

import pytest
from cryptography.fernet import Fernet
from httpx import ASGITransport, AsyncClient
from app.config import settings
@pytest.fixture
async def client(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
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 async_sessionmaker, create_async_engine
from app.db import base as db_base
# Import app.db.models before create_all — declarative models only register
# themselves into Base.metadata when their module is first imported, and
# nothing else in this fixture happens to trigger that import beforehand.
import app.db.models # noqa: F401
db_base.engine = create_async_engine(settings.database_url)
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
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 fastapi import FastAPI
from app.api.routes.users import router as users_router
from app.auth.routes import router as auth_router
from app.electrum.listener import ElectrumListener
app = FastAPI()
app.include_router(auth_router)
app.include_router(users_router)
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
await db_base.engine.dispose()
async def _register(client, username="alice", password="original-password"):
resp = await client.post("/auth/register", json={"username": username, "password": password})
assert resp.status_code == 201
return resp.json()["access_token"]
async def test_change_password_requires_current_password(client):
token = await _register(client)
headers = {"Authorization": f"Bearer {token}"}
resp = await client.post(
"/users/me/change-password",
headers=headers,
json={"current_password": "wrong-password", "new_password": "brand-new-password"},
)
assert resp.status_code == 401
async def test_change_password_updates_login(client):
token = await _register(client)
headers = {"Authorization": f"Bearer {token}"}
resp = await client.post(
"/users/me/change-password",
headers=headers,
json={"current_password": "original-password", "new_password": "brand-new-password"},
)
assert resp.status_code == 200
assert resp.json()["access_token"]
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
assert resp.status_code == 401
resp = await client.post("/auth/login", json={"username": "alice", "password": "brand-new-password"})
assert resp.status_code == 200
async def test_change_password_invalidates_the_old_token_but_not_the_new_one(client):
"""B-34: neither self-service change-password nor the admin reset used to
invalidate already-issued JWTs, so a stolen token (or an attacker who
already had the old password) stayed logged in until the token's natural
24h expiry — even past a password change meant to lock them out."""
old_token = await _register(client)
old_headers = {"Authorization": f"Bearer {old_token}"}
resp = await client.post(
"/users/me/change-password",
headers=old_headers,
json={"current_password": "original-password", "new_password": "brand-new-password"},
)
assert resp.status_code == 200
new_token = resp.json()["access_token"]
assert new_token != old_token
# The old token (what an attacker holding the old password would still
# have) is now rejected...
resp = await client.get("/users/me", headers=old_headers)
assert resp.status_code == 401
assert resp.json()["detail"]["code"] == "session_expired"
# ...but the freshly issued one keeps this same session working, so the
# user who just changed their own password isn't logged out too.
new_headers = {"Authorization": f"Bearer {new_token}"}
resp = await client.get("/users/me", headers=new_headers)
assert resp.status_code == 200
async def test_change_password_rejects_too_short(client):
token = await _register(client)
headers = {"Authorization": f"Bearer {token}"}
resp = await client.post(
"/users/me/change-password",
headers=headers,
json={"current_password": "original-password", "new_password": "short"},
)
assert resp.status_code == 400
async def test_change_password_requires_auth(client):
resp = await client.post(
"/users/me/change-password",
json={"current_password": "x", "new_password": "brand-new-password"},
)
assert resp.status_code in (401, 403)
@pytest.mark.parametrize(
"payload",
[
{"username": "", "password": "longenough1"},
{"username": "ab", "password": "longenough1"}, # under 3 chars
{"username": "bad user!", "password": "longenough1"}, # disallowed characters
{"username": "validname", "password": "short"}, # under MIN_PASSWORD_LENGTH
{"username": "validname", "password": ""},
],
)
async def test_register_rejects_weak_credentials(client, payload):
"""B-12: registration accepted an empty username and a one-character password,
while /users/me/change-password demanded 8 — an odd place to be lenient on a
custodial system holding real funds."""
resp = await client.post("/auth/register", json=payload)
assert resp.status_code == 422
async def test_register_accepts_valid_credentials(client):
resp = await client.post("/auth/register", json={"username": "goodname", "password": "longenough1"})
assert resp.status_code == 201
async def test_me_created_at_is_utc_stamped(client):
"""B-35: SQLite/aiosqlite returns DateTime columns as naive, even though every
value written is UTC (app.db.models.utcnow). A bare .isoformat() on that naive
value has no "Z"/offset, and JavaScript's `new Date()` then parses it as local
time instead of UTC."""
token = await _register(client)
headers = {"Authorization": f"Bearer {token}"}
resp = await client.get("/users/me", headers=headers)
assert resp.status_code == 200
created_at = resp.json()["created_at"]
assert created_at.endswith("+00:00") or created_at.endswith("Z")
async def test_me_shows_waiting_deposits_in_the_pending_balance_only(client): # B-82 (DEP-12 (b), DEP-14)
token = await _register(client)
headers = {"Authorization": f"Bearer {token}"}
me = (await client.get("/users/me", headers=headers)).json()
assert (me["balance_sats"], me["pending_balance_sats"], me["has_pending"]) == (0, 0, False)
listener = client._transport.app.state.electrum_listener
listener._pending_deposits[me["id"]] = [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 5, "value": 250_000_000}]
shown = (await client.get("/users/me", headers=headers)).json()
assert shown["balance_sats"] == 0 # not spendable
assert shown["pending_balance_sats"] == 250_000_000 and shown["has_pending"] is True