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