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