Files
plm-lottery/tests/unit/test_users.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

134 lines
4.7 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 == 204
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_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