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

32 lines
1.2 KiB
Python

from app.auth import security
def test_password_hash_roundtrip():
hashed = security.hash_password("s3cret!")
assert security.verify_password("s3cret!", hashed)
assert not security.verify_password("wrong", hashed)
def test_jwt_roundtrip(monkeypatch):
monkeypatch.setattr(security.settings, "jwt_secret", "test-secret")
token = security.create_access_token(user_id=42)
assert security.decode_access_token(token) == 42
def test_verify_password_returns_false_for_an_unparseable_hash():
"""B-13: only VerifyMismatchError was caught, so a corrupted stored hash raised
InvalidHashError and became an unhandled 500 on the login endpoint instead of a
plain "wrong credentials" 401."""
from app.auth.security import verify_password
assert verify_password("whatever", "not-an-argon2-hash") is False
assert verify_password("whatever", "") is False
def test_verify_password_still_rejects_a_wrong_password():
from app.auth.security import hash_password, verify_password
stored = hash_password("correct-horse-battery")
assert verify_password("correct-horse-battery", stored) is True
assert verify_password("wrong", stored) is False