Files
plm-lottery/tests/unit/test_admin.py
T
davideandClaude Opus 5 85dce221c5 Validate admin config and registration input, and log config changes
fee_address was the dangerous one (B-05). PUT /admin/config assigned whatever it
was given, and a well-formed address from another chain (bc1...) parses fine as a
witness program — so every round's 30% commission would be signed and broadcast
to a script nobody holds the key for. A malformed one instead wedged the payout
with an unhandled EmbitError. It now has to pass is_valid_plm_address, the same
check user withdrawals already had. Numeric fields got bounds too:
fee_rate_sat_vb=0 produces transactions no node relays, which stalls bets,
payouts and withdrawals alike, and round_duration_seconds=0 expires a round the
instant it opens.

Config changes are audit-logged (B-10). /pause and /resume were logged but a
config edit wasn't, so the most sensitive setting in the system could be changed
without leaving any trace — contradicting CLAUDE.md, which says audit_log records
what changed. The entry carries a before/after diff per field, computed before
assignment, and no-op updates write nothing. `paused` was removed from
_CONFIG_FIELDS so the maintenance switch has exactly one audited path; it stays
in the response model.

Admin token comparison is constant-time (B-14), with the empty-token check kept
*ahead* of it: compare_digest("", "") returns True, so the obvious ordering would
have opened the panel on any instance without an ADMIN_TOKEN.

Registration input (B-12). It 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. MIN_PASSWORD_LENGTH moved to
auth/security.py so both share it, and the username is constrained to 3-32 chars
of [A-Za-z0-9_.-]. The IntegrityError handler also distinguishes a username
collision (answers username_taken) from a derivation-index one (retries): a
concurrent duplicate username used to be retried five times and then reported as
derivation_index_conflict, which told the user the wrong thing.

verify_password (B-13) catches VerificationError and InvalidHashError, not just
VerifyMismatchError, so an unparseable stored hash reads as "wrong password"
instead of a 500 — logged as an error, since that one is a data problem.

guida-admin.md gains a table of the audit events worth watching, including
payout_failed, which needs manual intervention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:32:48 +02:00

276 lines
10 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_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)
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},
],
)
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