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>
32 lines
1.2 KiB
Python
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
|