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>
This commit is contained in:
+56
-12
@@ -2,7 +2,7 @@ import json
|
|||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -12,16 +12,26 @@ from app.config import settings
|
|||||||
from app.db.models import AuditLog, PendingTransaction, Round, User
|
from app.db.models import AuditLog, PendingTransaction, Round, User
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.rounds.config import get_round_config
|
from app.rounds.config import get_round_config
|
||||||
|
from app.wallet.address import is_valid_plm_address
|
||||||
from app.wallet.hd import derive_user_wif
|
from app.wallet.hd import derive_user_wif
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
async def require_admin(x_admin_token: str = Header(default="")) -> None:
|
async def require_admin(x_admin_token: str = Header(default="")) -> None:
|
||||||
if not settings.admin_token or x_admin_token != settings.admin_token:
|
# An unset ADMIN_TOKEN denies everything — checked first, since compare_digest
|
||||||
|
# on two empty strings returns True and would otherwise open the panel to
|
||||||
|
# anyone on an instance that never configured a token.
|
||||||
|
if not settings.admin_token:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token")
|
||||||
|
if not secrets.compare_digest(x_admin_token, settings.admin_token):
|
||||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token")
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token")
|
||||||
|
|
||||||
|
|
||||||
|
# `paused` is deliberately NOT here: it has its own audit-logged endpoints
|
||||||
|
# (/admin/pause, /admin/resume), and accepting it on PUT /config as well gave the
|
||||||
|
# operator an unlogged way to stop the lottery (B-10). It stays in the response
|
||||||
|
# model, so the dashboard still reads its current value from here.
|
||||||
_CONFIG_FIELDS = (
|
_CONFIG_FIELDS = (
|
||||||
"fee_address",
|
"fee_address",
|
||||||
"bet_amount_sats",
|
"bet_amount_sats",
|
||||||
@@ -30,7 +40,6 @@ _CONFIG_FIELDS = (
|
|||||||
"fee_rate_sat_vb",
|
"fee_rate_sat_vb",
|
||||||
"rbf_timeout_seconds",
|
"rbf_timeout_seconds",
|
||||||
"draw_animation_seconds",
|
"draw_animation_seconds",
|
||||||
"paused",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -46,18 +55,41 @@ class RoundConfigResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class RoundConfigUpdate(BaseModel):
|
class RoundConfigUpdate(BaseModel):
|
||||||
|
"""Bounds are enforced here rather than trusting the operator: a value like
|
||||||
|
fee_rate_sat_vb=0 produces transactions no node will relay (stalling every bet,
|
||||||
|
payout and withdrawal), and round_duration_seconds=0 expires a round the instant
|
||||||
|
it opens. `paused` is not accepted — see _CONFIG_FIELDS."""
|
||||||
|
|
||||||
|
# An unvalidated fee_address was the worst of the lot: a malformed one wedged the
|
||||||
|
# payout with an unhandled EmbitError, and a well-formed *foreign* one (bc1...)
|
||||||
|
# parses fine as a witness program, so every round's 30 % commission would be
|
||||||
|
# broadcast to a script nobody holds the key for (B-05).
|
||||||
fee_address: str | None = None
|
fee_address: str | None = None
|
||||||
bet_amount_sats: int | None = None
|
bet_amount_sats: int | None = Field(default=None, gt=0, le=100_000 * 100_000_000)
|
||||||
round_duration_seconds: int | None = None
|
round_duration_seconds: int | None = Field(default=None, ge=30, le=7 * 24 * 3600)
|
||||||
round_cooldown_seconds: int | None = None
|
round_cooldown_seconds: int | None = Field(default=None, ge=0, le=24 * 3600)
|
||||||
fee_rate_sat_vb: int | None = None
|
fee_rate_sat_vb: int | None = Field(default=None, ge=1, le=10_000)
|
||||||
rbf_timeout_seconds: int | None = None
|
rbf_timeout_seconds: int | None = Field(default=None, ge=60, le=7 * 24 * 3600)
|
||||||
draw_animation_seconds: int | None = None
|
draw_animation_seconds: int | None = Field(default=None, ge=0, le=600)
|
||||||
paused: bool | None = None
|
|
||||||
|
@field_validator("fee_address")
|
||||||
|
@classmethod
|
||||||
|
def _validate_fee_address(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
value = value.strip()
|
||||||
|
if not is_valid_plm_address(value):
|
||||||
|
raise ValueError(
|
||||||
|
"fee_address must be a valid PLM bech32 address (plm1...) — an address from "
|
||||||
|
"another chain would send every round's commission somewhere unspendable"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _config_response(config) -> RoundConfigResponse:
|
def _config_response(config) -> RoundConfigResponse:
|
||||||
return RoundConfigResponse(**{field: getattr(config, field) for field in _CONFIG_FIELDS})
|
fields = {field: getattr(config, field) for field in _CONFIG_FIELDS}
|
||||||
|
fields["paused"] = config.paused
|
||||||
|
return RoundConfigResponse(**fields)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
|
@router.get("/config", response_model=RoundConfigResponse, dependencies=[Depends(require_admin)])
|
||||||
@@ -72,10 +104,22 @@ async def update_config(
|
|||||||
body: RoundConfigUpdate, session: AsyncSession = Depends(get_session)
|
body: RoundConfigUpdate, session: AsyncSession = Depends(get_session)
|
||||||
) -> RoundConfigResponse:
|
) -> RoundConfigResponse:
|
||||||
config = await get_round_config(session)
|
config = await get_round_config(session)
|
||||||
|
# Diff computed before assignment so the audit entry records both sides. Without
|
||||||
|
# it, the most sensitive setting in the system (fee_address — where 30 % of every
|
||||||
|
# pool goes) could be changed without leaving any trace at all (B-10).
|
||||||
|
changes: dict[str, dict] = {}
|
||||||
for field in _CONFIG_FIELDS:
|
for field in _CONFIG_FIELDS:
|
||||||
value = getattr(body, field)
|
value = getattr(body, field)
|
||||||
if value is not None:
|
if value is None:
|
||||||
|
continue
|
||||||
|
previous = getattr(config, field)
|
||||||
|
if previous == value:
|
||||||
|
continue
|
||||||
|
changes[field] = {"from": previous, "to": value}
|
||||||
setattr(config, field, value)
|
setattr(config, field, value)
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
await write_audit_log(session, "config_updated", changes)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return _config_response(config)
|
return _config_response(config)
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.errors import http_error
|
from app.api.errors import http_error
|
||||||
from app.auth.dependencies import get_current_user
|
from app.auth.dependencies import get_current_user
|
||||||
from app.auth.security import hash_password, verify_password
|
from app.auth.security import MIN_PASSWORD_LENGTH, hash_password, verify_password
|
||||||
from app.db.models import Round, RoundParticipant, User
|
from app.db.models import Round, RoundParticipant, User
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.wallet.balance import compute_pending_balance
|
from app.wallet.balance import compute_pending_balance
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
_MIN_PASSWORD_LENGTH = 8
|
|
||||||
|
|
||||||
|
|
||||||
class MeResponse(BaseModel):
|
class MeResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
@@ -60,12 +58,12 @@ async def change_password(
|
|||||||
raise http_error(
|
raise http_error(
|
||||||
status.HTTP_401_UNAUTHORIZED, "current_password_incorrect", "current password is incorrect"
|
status.HTTP_401_UNAUTHORIZED, "current_password_incorrect", "current password is incorrect"
|
||||||
)
|
)
|
||||||
if len(body.new_password) < _MIN_PASSWORD_LENGTH:
|
if len(body.new_password) < MIN_PASSWORD_LENGTH:
|
||||||
raise http_error(
|
raise http_error(
|
||||||
status.HTTP_400_BAD_REQUEST,
|
status.HTTP_400_BAD_REQUEST,
|
||||||
"password_too_short",
|
"password_too_short",
|
||||||
f"new password must be at least {_MIN_PASSWORD_LENGTH} characters",
|
f"new password must be at least {MIN_PASSWORD_LENGTH} characters",
|
||||||
minimum=_MIN_PASSWORD_LENGTH,
|
minimum=MIN_PASSWORD_LENGTH,
|
||||||
)
|
)
|
||||||
|
|
||||||
user.password_hash = hash_password(body.new_password)
|
user.password_hash = hash_password(body.new_password)
|
||||||
|
|||||||
+18
-5
@@ -1,11 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends, Request, status
|
from fastapi import APIRouter, Depends, Request, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.errors import http_error
|
from app.api.errors import http_error
|
||||||
from app.auth.security import create_access_token, hash_password, verify_password
|
from app.auth.security import MIN_PASSWORD_LENGTH, create_access_token, hash_password, verify_password
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.wallet.hd import derive_user_address
|
from app.wallet.hd import derive_user_address
|
||||||
@@ -16,8 +16,13 @@ _MAX_REGISTER_RETRIES = 5
|
|||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
username: str
|
"""Registration used to accept an empty username and a one-character password,
|
||||||
password: str
|
while /users/me/change-password demanded 8 characters — an odd place to be
|
||||||
|
lenient on a custodial system holding real funds (B-12). MIN_PASSWORD_LENGTH is
|
||||||
|
shared with that endpoint so the two can't drift apart again."""
|
||||||
|
|
||||||
|
username: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_.-]+$")
|
||||||
|
password: str = Field(min_length=MIN_PASSWORD_LENGTH, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
@@ -48,8 +53,16 @@ async def register(
|
|||||||
session.add(user)
|
session.add(user)
|
||||||
try:
|
try:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except IntegrityError:
|
except IntegrityError as exc:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
# Only a derivation-index collision is worth retrying. A username
|
||||||
|
# collision (someone registered the same name between the check above and
|
||||||
|
# this commit) is permanent, and retrying it five times only to report
|
||||||
|
# "derivation_index_conflict" told the user the wrong thing entirely (B-12).
|
||||||
|
if "username" in str(exc.orig).lower():
|
||||||
|
raise http_error(
|
||||||
|
status.HTTP_409_CONFLICT, "username_taken", "username already taken"
|
||||||
|
) from exc
|
||||||
continue
|
continue
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
request.app.state.electrum_listener.address_for_new_user(user.id, user.address)
|
request.app.state.electrum_listener.address_for_new_user(user.id, user.address)
|
||||||
|
|||||||
+21
-2
@@ -1,11 +1,19 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import VerifyMismatchError
|
from argon2.exceptions import InvalidHashError, VerificationError
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Shared by registration (app/auth/routes.py) and the self-service password change
|
||||||
|
# (app/api/routes/users.py) so the two can't enforce different minimums.
|
||||||
|
MIN_PASSWORD_LENGTH = 8
|
||||||
|
|
||||||
_hasher = PasswordHasher()
|
_hasher = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
@@ -14,9 +22,20 @@ def hash_password(password: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def verify_password(password: str, password_hash: str) -> bool:
|
def verify_password(password: str, password_hash: str) -> bool:
|
||||||
|
"""Any failure to verify reads as "wrong password", never as a server error.
|
||||||
|
|
||||||
|
Catching only VerifyMismatchError left the other two cases as unhandled 500s
|
||||||
|
(B-13): VerificationError covers argon2's other verification failures, and
|
||||||
|
InvalidHashError fires when the stored hash can't be parsed at all — which is a
|
||||||
|
data problem worth logging, but from the caller's side it still just means this
|
||||||
|
password does not open this account.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
return _hasher.verify(password_hash, password)
|
return _hasher.verify(password_hash, password)
|
||||||
except VerifyMismatchError:
|
except InvalidHashError:
|
||||||
|
logger.error("stored password hash is unparseable — password verification cannot succeed")
|
||||||
|
return False
|
||||||
|
except VerificationError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,16 @@ piazzate, payout inviati, round chiusi, configurazione modificata, accessi
|
|||||||
alle chiavi private, ecc. È il primo posto da controllare per ricostruire
|
alle chiavi private, ecc. È il primo posto da controllare per ricostruire
|
||||||
cosa è successo dopo un problema.
|
cosa è successo dopo un problema.
|
||||||
|
|
||||||
|
Eventi a cui vale la pena prestare attenzione:
|
||||||
|
|
||||||
|
| Evento | Significato |
|
||||||
|
|---|---|
|
||||||
|
| `config_updated` | Un parametro è stato modificato; il payload contiene valore precedente e nuovo per ogni campo cambiato. |
|
||||||
|
| `bet_broadcast_failed` / `withdrawal_broadcast_failed` | La rete ha rifiutato la transazione. Non è stato speso nulla: gli UTXO sono stati liberati e il saldo dell'utente è tornato come prima. |
|
||||||
|
| `pending_tx_abandoned` | Una transazione trasmessa è scomparsa dalla catena e il sistema l'ha dichiarata persa: UTXO liberati, bet rimossa o prelievo segnato `failed`. Se capita spesso, la fee rate configurata è probabilmente troppo bassa. |
|
||||||
|
| `pending_tx_recovered` | Una transazione che si credeva incompleta è invece finita in catena (tipicamente dopo un riavvio a metà invio) e il sistema l'ha ripresa da sé. |
|
||||||
|
| `payout_failed` | Il payout di un round non è partito. Il round resta in `paying_out` e **richiede intervento manuale**: non esiste un retry automatico. Controlla `fee_address`, il saldo dell'indirizzo pool e la connessione Electrum. |
|
||||||
|
|
||||||
## Alternative all'interfaccia grafica
|
## Alternative all'interfaccia grafica
|
||||||
|
|
||||||
Le stesse operazioni si possono fare da terminale o da Swagger UI
|
Le stesse operazioni si possono fare da terminale o da Swagger UI
|
||||||
@@ -125,6 +135,18 @@ curl -X PUT https://<host>/admin/config \
|
|||||||
-d '{"fee_address": "plm1q...", "bet_amount_sats": 1000000000, "round_duration_seconds": 600}'
|
-d '{"fee_address": "plm1q...", "bet_amount_sats": 1000000000, "round_duration_seconds": 600}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
I valori vengono validati: `fee_address` deve essere un indirizzo bech32 PLM
|
||||||
|
valido (`plm1...`) e i parametri numerici hanno limiti di buon senso
|
||||||
|
(`fee_rate_sat_vb` almeno 1, durata round almeno 30s, ecc.). Un valore fuori
|
||||||
|
range viene rifiutato con un errore 422 e la configurazione resta invariata.
|
||||||
|
Il controllo su `fee_address` è deliberatamente severo: un indirizzo di
|
||||||
|
un'altra catena (per esempio `bc1...`) sarebbe formalmente valido come witness
|
||||||
|
program, e il 30% di commissione di ogni round finirebbe su uno script di cui
|
||||||
|
nessuno ha la chiave.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
```
|
||||||
|
|
||||||
## Utenti, chiave privata e reset password
|
## Utenti, chiave privata e reset password
|
||||||
|
|
||||||
La card "Utenti" elenca id, username, indirizzo e saldo di ogni utente
|
La card "Utenti" elenca id, username, indirizzo e saldo di ogni utente
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ from httpx import ASGITransport, AsyncClient
|
|||||||
|
|
||||||
from app.config import settings
|
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
|
@pytest.fixture
|
||||||
async def client(monkeypatch, tmp_path):
|
async def client(monkeypatch, tmp_path):
|
||||||
@@ -70,15 +75,15 @@ async def test_admin_reads_and_updates_config(client):
|
|||||||
assert resp.json()["fee_address"] == ""
|
assert resp.json()["fee_address"] == ""
|
||||||
|
|
||||||
resp = await client.put(
|
resp = await client.put(
|
||||||
"/admin/config", headers=headers, json={"fee_address": "plm1qfeeaddress", "bet_amount_sats": 500_000_000}
|
"/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS, "bet_amount_sats": 500_000_000}
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["fee_address"] == "plm1qfeeaddress"
|
assert body["fee_address"] == _VALID_FEE_ADDRESS
|
||||||
assert body["bet_amount_sats"] == 500_000_000
|
assert body["bet_amount_sats"] == 500_000_000
|
||||||
|
|
||||||
resp = await client.get("/admin/config", headers=headers)
|
resp = await client.get("/admin/config", headers=headers)
|
||||||
assert resp.json()["fee_address"] == "plm1qfeeaddress"
|
assert resp.json()["fee_address"] == _VALID_FEE_ADDRESS
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_can_pause_and_resume_the_lottery(client):
|
async def test_admin_can_pause_and_resume_the_lottery(client):
|
||||||
@@ -211,3 +216,60 @@ async def test_admin_reset_password_404_for_unknown_user(client):
|
|||||||
headers = {"X-Admin-Token": "test-admin-token"}
|
headers = {"X-Admin-Token": "test-admin-token"}
|
||||||
resp = await client.post("/admin/users/999/reset-password", headers=headers)
|
resp = await client.post("/admin/users/999/reset-password", headers=headers)
|
||||||
assert resp.status_code == 404
|
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
|
||||||
|
|||||||
@@ -11,3 +11,21 @@ def test_jwt_roundtrip(monkeypatch):
|
|||||||
monkeypatch.setattr(security.settings, "jwt_secret", "test-secret")
|
monkeypatch.setattr(security.settings, "jwt_secret", "test-secret")
|
||||||
token = security.create_access_token(user_id=42)
|
token = security.create_access_token(user_id=42)
|
||||||
assert security.decode_access_token(token) == 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
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ async def client(monkeypatch, tmp_path):
|
|||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
app.state.electrum_listener = ElectrumListener(lambda: None, db_base.AsyncSessionLocal)
|
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
|
||||||
|
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
@@ -108,3 +108,26 @@ async def test_change_password_requires_auth(client):
|
|||||||
json={"current_password": "x", "new_password": "brand-new-password"},
|
json={"current_password": "x", "new_password": "brand-new-password"},
|
||||||
)
|
)
|
||||||
assert resp.status_code in (401, 403)
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user