Bound credentials, brake password changes, cap bug reports, page the user list, structure every error (B-94, SEC-54, SEC-55, API-09, API-15, API-16, API-19, API-21, API-28, API-33, REG-12, AUD-42)
- login and password-change fields: at most 256 characters
- wrong current password: per-user brake (5 free, 2s doubling to 300s), 429 even for the
right password while locked; password_changed audited in the same transaction
- POST /bug-reports: 10 per IP per hour, consumed only by a stored report
- GET /admin/users: limit/after_id paging, the panel walks the pages
- 401/403/404/405/422/503 all answer {code, message, params}; the 422 omits the input
- static files revalidated on every use (Cache-Control: no-cache)
- error.* keys for the new codes in all seven languages
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c1b192f6d1
commit
24e5fc3d8b
23 files changed
+374
-111
No files matched your search
@@ -1,7 +1,7 @@
|
||||
# BUGS.md — open findings
|
||||
|
||||
Only the 2026-09-28 specification review (B-73 … B-100) still has open entries
|
||||
(B-92, B-93, B-94, B-96). A finding is removed from this file once it is fixed, and
|
||||
(B-92, B-93, B-96). A finding is removed from this file once it is fixed, and
|
||||
`git log --all --grep 'B-nn'` is the record of how it was closed. Per CLAUDE.md's
|
||||
convention each entry gets its own commit with its own regression test, and the `B-nn`
|
||||
marker goes in a comment next to the fix.
|
||||
@@ -57,16 +57,6 @@ Fix: close SSE streams on shutdown, `stop_grace_period: 30s`, await task
|
||||
cancellation; tag images per release; `SITE_ADDRESS` in `.env` with `${SITE_ADDRESS:?}`;
|
||||
documented pre-deploy backup and downgrade procedure.
|
||||
|
||||
### B-94 — Account and endpoint abuse limits
|
||||
|
||||
`change-password` has no rate limit, no audit and no maximum length; login fields
|
||||
have no length bounds; `POST /bug-reports` is anonymous and unlimited;
|
||||
`GET /admin/users` has no pagination; admin, SSE and framework errors are bare
|
||||
strings; static JS/CSS have no `Cache-Control`.
|
||||
|
||||
Fix: throttle and audit (`password_changed`) the password change, cap every
|
||||
credential field at 256, rate-limit bug reports per IP, paginate the user list,
|
||||
structured errors everywhere, `Cache-Control: no-cache` on static files.
|
||||
|
||||
### B-96 — Smaller state inconsistencies
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ The user communicates in Italian in chat — reply to them in Italian. Everythin
|
||||
|
||||
## Project status
|
||||
|
||||
All 10 stages of the original build order are code-complete and unit-tested — 728 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
||||
All 10 stages of the original build order are code-complete and unit-tested — 738 tests, all under `tests/unit/` (`tests/integration/` is an empty package). Beyond them: Docker + Caddy deployment, admin dashboard (`/admin`), static test UI (`/`), pending-inclusive balance display, an SSE push channel layered over the original polling, self-service password change + admin password reset, and the reconciliation/corroboration machinery below.
|
||||
|
||||
Verified on mainnet with real money: registration + address derivation, deposit crediting (at the time, 1-conf; deposits now need `deposit_confirmations`), a real 10 PLM bet (broadcast → confirmed → change credited back), and one full round cycle (close → draw on a real block hash → 70/30 payout with sat math checked against the broadcast tx → confirmation → close → next round auto-opened). **Withdrawal and the RBF bump path have never been exercised against a live broadcast** — unit-tested only.
|
||||
|
||||
@@ -49,7 +49,7 @@ PYTHONPATH=. python scripts/decrypt_master_key.py # ops recovery: decrypt+pr
|
||||
PYTHONPATH=. python scripts/encrypt_master_key.py # ops bootstrap: import an externally-generated xprv (--overwrite to replace)
|
||||
PYTHONPATH=. python scripts/electrum_smoke_test.py # manual check: connect, handshake, subscribe to headers, print the tip
|
||||
|
||||
python -m pytest # all 728 tests
|
||||
python -m pytest # all 738 tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
python -m pytest tests/unit/test_hd.py::test_derivation_is_deterministic # one test
|
||||
```
|
||||
|
||||
@@ -62,7 +62,7 @@ python -m pytest # all tests
|
||||
python -m pytest tests/unit/test_hd.py # one file
|
||||
```
|
||||
|
||||
728 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
738 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
|
||||
deposits, withdrawals, the round/draw engine, RBF fee-bumping, admin config,
|
||||
the pending-inclusive balance calculation, and the SSE push channel. No
|
||||
automated integration tests against a live Electrum connection — mainnet
|
||||
|
||||
+17
-8
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, Header, Query, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -28,11 +28,11 @@ async def require_admin(x_admin_token: str = Header(default="")) -> None:
|
||||
# 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")
|
||||
raise http_error(status.HTTP_403_FORBIDDEN, "invalid_admin_token", "invalid admin token")
|
||||
# compare_digest raises TypeError on a str containing non-ASCII characters
|
||||
# (B-46) -- comparing the UTF-8 bytes instead accepts any input safely.
|
||||
if not secrets.compare_digest(x_admin_token.encode(), settings.admin_token.encode()):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid admin token")
|
||||
raise http_error(status.HTTP_403_FORBIDDEN, "invalid_admin_token", "invalid admin token")
|
||||
|
||||
|
||||
# `paused` is deliberately NOT here: it has its own audit-logged endpoints
|
||||
@@ -226,8 +226,17 @@ class AdminUserResponse(BaseModel):
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[AdminUserResponse], dependencies=[Depends(require_admin)])
|
||||
async def list_users(session: AsyncSession = Depends(get_session)) -> list[AdminUserResponse]:
|
||||
users = (await session.scalars(select(User).order_by(User.id))).all()
|
||||
async def list_users(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
after_id: int | None = Query(default=None, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[AdminUserResponse]:
|
||||
"""B-94 (API-33): by ascending id, a page at a time; the next page asks for `after_id` equal
|
||||
to the last id received, and a list shorter than `limit` is the last one."""
|
||||
query = select(User).order_by(User.id).limit(limit)
|
||||
if after_id is not None:
|
||||
query = query.where(User.id > after_id)
|
||||
users = (await session.scalars(query)).all()
|
||||
return [
|
||||
AdminUserResponse(
|
||||
id=u.id,
|
||||
@@ -254,7 +263,7 @@ async def user_privkey(user_id: int, session: AsyncSession = Depends(get_session
|
||||
the most sensitive data the platform holds."""
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
|
||||
raise http_error(status.HTTP_404_NOT_FOUND, "user_not_found", "user not found")
|
||||
|
||||
wif = derive_user_wif(user.derivation_index)
|
||||
await write_audit_log(session, "admin_privkey_accessed", {"user_id": user_id}, user_id=user_id)
|
||||
@@ -282,7 +291,7 @@ async def reset_user_password(
|
||||
reset; only an admin (via /admin, token-gated) can trigger this."""
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
|
||||
raise http_error(status.HTTP_404_NOT_FOUND, "user_not_found", "user not found")
|
||||
|
||||
new_password = secrets.token_urlsafe(12)
|
||||
user.password_hash = await hash_password_async(new_password)
|
||||
@@ -484,7 +493,7 @@ async def update_bug_report_status(
|
||||
) -> AdminBugReportResponse:
|
||||
report = await session.get(BugReport, report_id)
|
||||
if report is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "bug report not found")
|
||||
raise http_error(status.HTTP_404_NOT_FOUND, "bug_report_not_found", "bug report not found")
|
||||
|
||||
# B-60: every other admin mutation (config edit, pause/resume, privkey export,
|
||||
# password reset) leaves a trace; this one silently marked a report `resolved`.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.client_ip import client_ip
|
||||
from app.api.timeutil import isoformat_utc
|
||||
from app.auth.dependencies import get_current_user, get_optional_user
|
||||
from app.auth.routes import _rate_limited_error, _rate_limiters
|
||||
from app.db.models import BugReport, User
|
||||
from app.db.session import get_session
|
||||
|
||||
@@ -39,9 +41,17 @@ class BugReportResponse(BaseModel):
|
||||
@router.post("", response_model=BugReportResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_bug_report(
|
||||
body: BugReportCreate,
|
||||
request: Request,
|
||||
user: User | None = Depends(get_optional_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> BugReportResponse:
|
||||
# B-94 (SEC-55): a quota per IP, checked before the insert and consumed only by a report
|
||||
# that was stored (validation failures never get here).
|
||||
quota = _rate_limiters(request).bug_reports
|
||||
ip_key = f"ip:{client_ip(request)}"
|
||||
retry_after = quota.retry_after(ip_key)
|
||||
if retry_after > 0:
|
||||
raise _rate_limited_error(retry_after)
|
||||
report = BugReport(
|
||||
description=body.description,
|
||||
contact=body.contact,
|
||||
@@ -49,6 +59,7 @@ async def create_bug_report(
|
||||
)
|
||||
session.add(report)
|
||||
await session.commit()
|
||||
quota.record(ip_key)
|
||||
return BugReportResponse(id=report.id)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -57,7 +57,7 @@ async def round_stream(request: Request) -> Response:
|
||||
try:
|
||||
queue = broadcaster.subscribe(client_ip(request))
|
||||
except RoundEventCapacityError:
|
||||
return JSONResponse(status_code=503, content={"detail": "too many concurrent update streams"})
|
||||
raise http_error(503, "too_many_streams", "too many concurrent update streams")
|
||||
|
||||
async def event_generator():
|
||||
ticks_since_keepalive = 0
|
||||
|
||||
+16
-3
@@ -1,11 +1,13 @@
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import http_error
|
||||
from app.audit.log import write_audit_log
|
||||
from app.api.timeutil import isoformat_utc
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.auth.routes import _rate_limited_error, _rate_limiters
|
||||
from app.auth.security import (
|
||||
MIN_PASSWORD_LENGTH,
|
||||
create_access_token,
|
||||
@@ -52,8 +54,8 @@ async def me(
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
current_password: str = Field(max_length=256) # B-94 (REG-12)
|
||||
new_password: str = Field(max_length=256)
|
||||
|
||||
|
||||
class ChangePasswordResponse(BaseModel):
|
||||
@@ -63,13 +65,22 @@ class ChangePasswordResponse(BaseModel):
|
||||
@router.post("/me/change-password", response_model=ChangePasswordResponse)
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ChangePasswordResponse:
|
||||
"""Self-service password change — requires the current password, unlike the
|
||||
admin-only /admin/users/{id}/reset-password (which is for a user who's
|
||||
actually locked out and can't provide it)."""
|
||||
# B-94 (SEC-54): a stolen token must not be able to guess the current password, change it
|
||||
# and lock the owner out. Checked before the Argon2 hash and even for the right password.
|
||||
limiter = _rate_limiters(request).change_password
|
||||
key = f"user:{user.id}"
|
||||
retry_after = limiter.retry_after(key)
|
||||
if retry_after > 0:
|
||||
raise _rate_limited_error(retry_after)
|
||||
if not await verify_password_async(body.current_password, user.password_hash):
|
||||
limiter.record_failure(key)
|
||||
raise http_error(
|
||||
status.HTTP_401_UNAUTHORIZED, "current_password_incorrect", "current password is incorrect"
|
||||
)
|
||||
@@ -89,7 +100,9 @@ async def change_password(
|
||||
# every other open session (this user's other devices, or an attacker's)
|
||||
# gets "session_expired" on its next request.
|
||||
user.token_version += 1
|
||||
await write_audit_log(session, "password_changed", {"user_id": user.id}, user_id=user.id)
|
||||
await session.commit()
|
||||
limiter.record_success(key)
|
||||
return ChangePasswordResponse(access_token=create_access_token(user.id, user.token_version))
|
||||
|
||||
|
||||
|
||||
@@ -186,6 +186,8 @@ class RollingQuota:
|
||||
|
||||
_REGISTRATIONS_PER_IP = 5
|
||||
_REGISTRATION_WINDOW_SECONDS = 3600.0
|
||||
_BUG_REPORTS_PER_IP = 10
|
||||
_BUG_REPORT_WINDOW_SECONDS = 3600.0
|
||||
|
||||
|
||||
class AuthRateLimiters:
|
||||
@@ -208,3 +210,8 @@ class AuthRateLimiters:
|
||||
self.register_ip = RollingQuota(
|
||||
limit=_REGISTRATIONS_PER_IP, window_seconds=_REGISTRATION_WINDOW_SECONDS
|
||||
)
|
||||
# B-94 (SEC-54): wrong `current_password` on a password change, per user — a stolen
|
||||
# token must not be able to guess the password and lock the owner out.
|
||||
self.change_password = RateLimiter(threshold=5, base_delay=2.0, max_delay=300.0)
|
||||
# B-94 (SEC-55): bug reports are anonymous; a quota per IP keeps them from filling the DB.
|
||||
self.bug_reports = RollingQuota(limit=_BUG_REPORTS_PER_IP, window_seconds=_BUG_REPORT_WINDOW_SECONDS)
|
||||
+4
-2
@@ -148,8 +148,10 @@ async def register(
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
# B-94 (API-19): bounded before anything is hashed or counted; a megabyte password would
|
||||
# otherwise be an Argon2 input and a rate-limit key.
|
||||
username: str = Field(max_length=256)
|
||||
password: str = Field(max_length=256)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
|
||||
+35
-1
@@ -3,8 +3,10 @@ import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.logging_config import setup_logging
|
||||
|
||||
@@ -117,6 +119,28 @@ app.include_router(qr_router)
|
||||
app.include_router(rounds_router)
|
||||
|
||||
|
||||
# B-94 (API-15, API-16): no error answers with a bare-string or list `detail`; the framework's
|
||||
# own 401/404/405 and request-validation failures take the structured shape too.
|
||||
_FRAMEWORK_CODES = {401: "not_authenticated", 404: "not_found", 405: "method_not_allowed"}
|
||||
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def structured_http_exception(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
if isinstance(exc.detail, dict) and "code" in exc.detail:
|
||||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}, headers=exc.headers)
|
||||
code = _FRAMEWORK_CODES.get(exc.status_code, f"http_{exc.status_code}")
|
||||
detail = ApiError(code, str(exc.detail)).as_detail()
|
||||
return JSONResponse(status_code=exc.status_code, content={"detail": detail}, headers=exc.headers)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def structured_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
"""`input` is left out on purpose: an over-long password must not come back in the response."""
|
||||
errors = [{"loc": list(e["loc"]), "msg": e["msg"], "type": e["type"]} for e in exc.errors()]
|
||||
detail = ApiError("invalid_request", "invalid request", errors=errors).as_detail()
|
||||
return JSONResponse(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"detail": detail})
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def log_unhandled_exception(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Answers with the same structured `detail` shape as every deliberate failure
|
||||
@@ -157,4 +181,14 @@ async def report_bug_page() -> FileResponse:
|
||||
return FileResponse("app/static/report-bug.html", headers=_NO_STORE_HEADERS)
|
||||
|
||||
|
||||
app.mount("/", StaticFiles(directory="app/static", html=True), name="static")
|
||||
class NoCacheStaticFiles(StaticFiles):
|
||||
"""B-94 (API-09): the browser may keep a copy but revalidates it on every use, so after an
|
||||
update it does not run an old app.js against the new API."""
|
||||
|
||||
async def get_response(self, path, scope):
|
||||
response = await super().get_response(path, scope)
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
app.mount("/", NoCacheStaticFiles(directory="app/static", html=True), name="static")
|
||||
+10
-3
@@ -34,8 +34,8 @@ async function callAdmin(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken };
|
||||
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
// detail is a bare string on the admin endpoints, but the shared dependencies
|
||||
// (auth) answer with the structured {code, message} form of app/api/errors.py.
|
||||
// Every error answers with the structured {code, message, params} detail (API-16);
|
||||
// the bare-string fallback only covers a proxy's own error page.
|
||||
if (!res.ok) throw new Error(data.detail?.message || data.detail || res.statusText);
|
||||
return data;
|
||||
}
|
||||
@@ -230,7 +230,14 @@ async function adminSave() {
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const users = await callAdmin('GET', '/admin/users');
|
||||
// B-94 (API-33): the list comes in pages of up to 500, by ascending id.
|
||||
const users = [];
|
||||
for (;;) {
|
||||
const afterId = users.length ? users[users.length - 1].id : 0;
|
||||
const page = await callAdmin('GET', `/admin/users?limit=500&after_id=${afterId}`);
|
||||
users.push(...page);
|
||||
if (page.length < 500) break;
|
||||
}
|
||||
const tbody = document.getElementById('users-tbody');
|
||||
tbody.innerHTML = users.map((u) => `
|
||||
<tr>
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ async function call(method, path, body) {
|
||||
// A token the server no longer accepts can't be recovered from by retrying:
|
||||
// without this every poll keeps failing against a dashboard that still looks
|
||||
// logged in, toasting "session expired" forever. Drop back to the login form.
|
||||
if (res.status === 401 && data.detail?.code === 'session_expired' && token) logout();
|
||||
if (res.status === 401 && ['session_expired', 'not_authenticated'].includes(data.detail?.code) && token) logout();
|
||||
throw new Error(apiErrorMessage(data.detail) || res.statusText);
|
||||
}
|
||||
return data;
|
||||
|
||||
@@ -162,6 +162,13 @@ const TRANSLATIONS = {
|
||||
'error.broadcast_failed': 'The network refused the transaction. Please try again shortly.',
|
||||
'error.service_busy': 'The service is busy right now and nothing was sent. Please try again in a moment.',
|
||||
'error.registration_unavailable': 'Registration is temporarily unavailable. Please try again later.',
|
||||
'error.not_authenticated': 'You are not signed in. Please sign in again.',
|
||||
'error.not_found': 'Not found.',
|
||||
'error.method_not_allowed': 'This action is not allowed.',
|
||||
'error.too_many_streams': 'Too many live-update connections right now; the page will keep refreshing on its own.',
|
||||
'error.invalid_admin_token': 'The admin token is missing or wrong.',
|
||||
'error.user_not_found': 'User not found.',
|
||||
'error.bug_report_not_found': 'Bug report not found.',
|
||||
'error.amount_below_dust_limit': 'The amount is too small to be sent.',
|
||||
'error.too_many_inputs': 'Your balance is split across too many small deposits to be spent in a single transaction (max {max_inputs}). Please contact support to consolidate it.',
|
||||
'error.withdrawal_to_own_address': 'That is your own deposit address — withdraw to an external wallet.',
|
||||
@@ -331,6 +338,13 @@ const TRANSLATIONS = {
|
||||
'error.broadcast_failed': 'La rete ha rifiutato la transazione. Riprova tra poco.',
|
||||
'error.service_busy': 'Il servizio è occupato in questo momento e non è stato inviato nulla. Riprova tra poco.',
|
||||
'error.registration_unavailable': 'La registrazione è temporaneamente non disponibile. Riprova più tardi.',
|
||||
'error.not_authenticated': 'Non hai effettuato l\'accesso. Accedi di nuovo.',
|
||||
'error.not_found': 'Non trovato.',
|
||||
'error.method_not_allowed': 'Questa azione non è consentita.',
|
||||
'error.too_many_streams': 'Troppe connessioni di aggiornamento in tempo reale; la pagina continuerà ad aggiornarsi da sola.',
|
||||
'error.invalid_admin_token': 'Il token admin manca o è errato.',
|
||||
'error.user_not_found': 'Utente non trovato.',
|
||||
'error.bug_report_not_found': 'Segnalazione non trovata.',
|
||||
'error.amount_below_dust_limit': "L'importo è troppo basso per essere inviato.",
|
||||
'error.too_many_inputs': 'Il tuo saldo è suddiviso in troppi piccoli depositi per essere speso in una sola transazione (max {max_inputs}). Contatta l\'assistenza per consolidarlo.',
|
||||
'error.withdrawal_to_own_address': 'Questo è il tuo indirizzo di deposito — preleva verso un wallet esterno.',
|
||||
@@ -500,6 +514,13 @@ const TRANSLATIONS = {
|
||||
'error.broadcast_failed': 'La red rechazó la transacción. Inténtalo de nuevo en un momento.',
|
||||
'error.service_busy': 'El servicio está ocupado en este momento y no se ha enviado nada. Inténtalo de nuevo en un momento.',
|
||||
'error.registration_unavailable': 'El registro no está disponible temporalmente. Inténtalo de nuevo más tarde.',
|
||||
'error.not_authenticated': 'No has iniciado sesión. Inicia sesión de nuevo.',
|
||||
'error.not_found': 'No encontrado.',
|
||||
'error.method_not_allowed': 'Esta acción no está permitida.',
|
||||
'error.too_many_streams': 'Demasiadas conexiones de actualización en tiempo real; la página seguirá actualizándose sola.',
|
||||
'error.invalid_admin_token': 'Falta el token de administrador o es incorrecto.',
|
||||
'error.user_not_found': 'Usuario no encontrado.',
|
||||
'error.bug_report_not_found': 'Informe de error no encontrado.',
|
||||
'error.amount_below_dust_limit': 'El importe es demasiado pequeño para enviarse.',
|
||||
'error.too_many_inputs': 'Tu saldo está repartido en demasiados depósitos pequeños para gastarse en una sola transacción (máx. {max_inputs}). Contacta con soporte para consolidarlo.',
|
||||
'error.withdrawal_to_own_address': 'Esa es tu propia dirección de depósito — retira a una cartera externa.',
|
||||
@@ -669,6 +690,13 @@ const TRANSLATIONS = {
|
||||
'error.broadcast_failed': 'Le réseau a refusé la transaction. Veuillez réessayer dans un instant.',
|
||||
'error.service_busy': 'Le service est occupé pour le moment et rien n\'a été envoyé. Veuillez réessayer dans un instant.',
|
||||
'error.registration_unavailable': 'L\'inscription est temporairement indisponible. Veuillez réessayer plus tard.',
|
||||
'error.not_authenticated': 'Vous n\'êtes pas connecté. Veuillez vous reconnecter.',
|
||||
'error.not_found': 'Introuvable.',
|
||||
'error.method_not_allowed': 'Cette action n\'est pas autorisée.',
|
||||
'error.too_many_streams': 'Trop de connexions de mise à jour en direct ; la page continuera de s\'actualiser seule.',
|
||||
'error.invalid_admin_token': 'Le jeton d\'administration est absent ou incorrect.',
|
||||
'error.user_not_found': 'Utilisateur introuvable.',
|
||||
'error.bug_report_not_found': 'Signalement introuvable.',
|
||||
'error.amount_below_dust_limit': "Le montant est trop faible pour être envoyé.",
|
||||
'error.too_many_inputs': 'Votre solde est réparti sur trop de petits dépôts pour être dépensé en une seule transaction (max {max_inputs}). Contactez le support pour le consolider.',
|
||||
'error.withdrawal_to_own_address': "C'est votre propre adresse de dépôt — retirez vers un portefeuille externe.",
|
||||
@@ -838,6 +866,13 @@ const TRANSLATIONS = {
|
||||
'error.broadcast_failed': 'Das Netzwerk hat die Transaktion abgelehnt. Bitte versuche es in Kürze erneut.',
|
||||
'error.service_busy': 'Der Dienst ist gerade ausgelastet und es wurde nichts gesendet. Bitte versuche es gleich noch einmal.',
|
||||
'error.registration_unavailable': 'Die Registrierung ist vorübergehend nicht verfügbar. Bitte versuche es später noch einmal.',
|
||||
'error.not_authenticated': 'Du bist nicht angemeldet. Bitte melde dich erneut an.',
|
||||
'error.not_found': 'Nicht gefunden.',
|
||||
'error.method_not_allowed': 'Diese Aktion ist nicht erlaubt.',
|
||||
'error.too_many_streams': 'Zu viele Live-Update-Verbindungen; die Seite aktualisiert sich weiterhin selbst.',
|
||||
'error.invalid_admin_token': 'Das Admin-Token fehlt oder ist falsch.',
|
||||
'error.user_not_found': 'Benutzer nicht gefunden.',
|
||||
'error.bug_report_not_found': 'Fehlermeldung nicht gefunden.',
|
||||
'error.amount_below_dust_limit': 'Der Betrag ist zu klein, um gesendet zu werden.',
|
||||
'error.too_many_inputs': 'Ihr Guthaben ist auf zu viele kleine Einzahlungen verteilt, um in einer einzigen Transaktion ausgegeben zu werden (max. {max_inputs}). Bitte wenden Sie sich an den Support, um es zusammenzufassen.',
|
||||
'error.withdrawal_to_own_address': 'Das ist deine eigene Einzahlungsadresse — zahle auf eine externe Wallet aus.',
|
||||
@@ -1007,6 +1042,13 @@ const TRANSLATIONS = {
|
||||
'error.broadcast_failed': 'Сеть отклонила транзакцию. Попробуйте ещё раз через минуту.',
|
||||
'error.service_busy': 'Сервис сейчас занят, ничего не было отправлено. Попробуйте ещё раз через минуту.',
|
||||
'error.registration_unavailable': 'Регистрация временно недоступна. Попробуйте позже.',
|
||||
'error.not_authenticated': 'Вы не вошли в систему. Войдите снова.',
|
||||
'error.not_found': 'Не найдено.',
|
||||
'error.method_not_allowed': 'Это действие не разрешено.',
|
||||
'error.too_many_streams': 'Слишком много соединений для живых обновлений; страница продолжит обновляться сама.',
|
||||
'error.invalid_admin_token': 'Токен администратора отсутствует или неверен.',
|
||||
'error.user_not_found': 'Пользователь не найден.',
|
||||
'error.bug_report_not_found': 'Сообщение об ошибке не найдено.',
|
||||
'error.amount_below_dust_limit': 'Сумма слишком мала для отправки.',
|
||||
'error.too_many_inputs': 'Ваш баланс разбит на слишком много мелких депозитов, чтобы потратить его одной транзакцией (максимум {max_inputs}). Обратитесь в поддержку для консолидации.',
|
||||
'error.withdrawal_to_own_address': 'Это ваш собственный адрес для депозита — выводите на внешний кошелёк.',
|
||||
@@ -1176,6 +1218,13 @@ const TRANSLATIONS = {
|
||||
'error.broadcast_failed': '网络拒绝了该交易,请稍后重试。',
|
||||
'error.service_busy': '服务当前繁忙,没有发送任何内容,请稍后重试。',
|
||||
'error.registration_unavailable': '注册暂时不可用,请稍后再试。',
|
||||
'error.not_authenticated': '您尚未登录,请重新登录。',
|
||||
'error.not_found': '未找到。',
|
||||
'error.method_not_allowed': '不允许此操作。',
|
||||
'error.too_many_streams': '实时更新连接过多;页面将自行继续刷新。',
|
||||
'error.invalid_admin_token': '管理员令牌缺失或错误。',
|
||||
'error.user_not_found': '未找到用户。',
|
||||
'error.bug_report_not_found': '未找到错误报告。',
|
||||
'error.amount_below_dust_limit': '金额过小,无法发送。',
|
||||
'error.too_many_inputs': '您的余额分散在过多的小额存款中,无法在一笔交易中花费(最多 {max_inputs} 笔)。请联系客服进行归集。',
|
||||
'error.withdrawal_to_own_address': '这是你自己的充值地址 — 请提现到外部钱包。',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-41 — Limitazione dei tentativi solo su login e registrazione
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-94)
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Account, amministrazione e sicurezza
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
@@ -17,12 +17,10 @@ account creati davvero. I contatori sono limitati in numero.
|
||||
limita la produzione.
|
||||
- *Conseguenze:* nessun limite su scommesse, prelievi, admin ([LIM-07]).
|
||||
Il cambio password (limitato e registrato come `password_changed`) e le
|
||||
segnalazioni di bug (limitate per IP) si aggiungono con B-94; ogni campo
|
||||
segnalazioni di bug (limitate per IP) sono limitati (B-94); ogni campo
|
||||
delle credenziali ha una lunghezza massima di 256 caratteri
|
||||
([sicurezza.md](../sicurezza.md#limiti-di-frequenza), B-94).
|
||||
|
||||
*Codice:* `app/auth/rate_limit.py:AuthRateLimiters` · `app/auth/rate_limit.py:RateLimiter` · `app/auth/rate_limit.py:RollingQuota` · `app/auth/routes.py:login` · `app/api/routes/users.py:change_password` · `app/api/routes/bug_reports.py:create_bug_report` ·
|
||||
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* cambio password e segnalazioni di
|
||||
bug non hanno limiti né lunghezze massime, e il cambio password non scrive
|
||||
audit.
|
||||
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_rate_limit.py`, `tests/unit/test_abuse_limits.py`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-50 — Frontend statici, senza framework né build
|
||||
|
||||
- **Stato:** Accettata — implementazione da completare (B-94)
|
||||
- **Stato:** Accettata
|
||||
- **Area:** Interfacce, API e lingue
|
||||
- **Fonte di verità per:** la scelta di progetto descritta qui sotto; i requisiti (`PREFISSO-NN`) che la attuano stanno nei documenti di dominio, indicati nelle *Conseguenze*.
|
||||
|
||||
@@ -15,6 +15,3 @@ aggiornamento non lascia una pagina nuova con uno script vecchio;
|
||||
[architettura.md](../architettura.md#allimport), B-94).
|
||||
|
||||
*Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` · `app/static/app.js` · `app/static/admin.js`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* solo le quattro pagine HTML hanno
|
||||
`Cache-Control` (`no-store`); JS e CSS, serviti da `StaticFiles`, non ne
|
||||
hanno.
|
||||
+12
-21
@@ -117,7 +117,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
modo: torna alla schermata di login.
|
||||
*Codice:* `app/auth/dependencies.py:get_current_user` ·
|
||||
*Test:* `tests/unit/test_users.py`, `tests/unit/test_bug_reports.py`
|
||||
*Stato:* da implementare (B-94), per l'header assente · *Oggi:* l'assenza
|
||||
dell'header è intercettata da FastAPI prima del codice applicativo e
|
||||
risponde con il corpo non strutturato `{"detail": "Not authenticated"}`.
|
||||
|
||||
@@ -137,7 +136,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
(vedi [sicurezza.md](sicurezza.md)).
|
||||
*Codice:* `app/api/routes/admin.py:require_admin` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* `403` con il corpo non
|
||||
strutturato `{"detail": "invalid admin token"}`.
|
||||
|
||||
- **[API-09]** Intestazioni di cache:
|
||||
@@ -156,7 +154,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
`app/main.py:user_guide`, `app/main.py:report_bug_page`,
|
||||
`app/api/routes/rounds.py:round_stream`, `app/api/routes/qr.py:address_qr`,
|
||||
`app/main.py` (`app.mount`)
|
||||
*Stato:* da implementare (B-94), per i file statici · *Oggi:* i file
|
||||
serviti dal mount non hanno `Cache-Control`, solo `ETag`/`Last-Modified`,
|
||||
e il browser può riusarli senza rivalidarli.
|
||||
|
||||
@@ -169,8 +166,8 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
*Test:* `tests/unit/test_docs_disabled.py`
|
||||
|
||||
- **[API-11]** Dove un controllo dipende dall'indirizzo IP del chiamante (limiti
|
||||
di login e registrazione, tetto per IP del flusso SSE e, da implementare con
|
||||
B-94, quota per IP di `POST /bug-reports`), l'IP è l'**ultimo**
|
||||
di login e registrazione, tetto per IP del flusso SSE e quota per IP di
|
||||
`POST /bug-reports`), l'IP è l'**ultimo**
|
||||
elemento di `X-Forwarded-For` (quello scritto dal proxy fidato), o in sua
|
||||
assenza l'indirizzo della connessione, o `"unknown"`. Il motivo e il ruolo di
|
||||
Caddy sono in [sicurezza.md](sicurezza.md).
|
||||
@@ -238,11 +235,10 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
con `type: "json_invalid"`). Il valore ricevuto (`input`) **non** è
|
||||
ripetuto nella risposta, così una password troppo lunga non torna indietro
|
||||
in chiaro.
|
||||
*Codice:* `app/main.py` (gestore di `RequestValidationError`, da scrivere),
|
||||
*Codice:* `app/main.py` (gestore di `RequestValidationError`),
|
||||
`app/static/app.js:apiErrorMessage` ·
|
||||
*Test:* `tests/unit/test_users.py`, `tests/unit/test_admin.py`,
|
||||
`tests/unit/test_bug_reports.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* `422` nel formato **nativo** di
|
||||
FastAPI: `detail` è una **lista** di oggetti `{type, loc, msg, input, ctx?}`,
|
||||
senza `code`; il frontend riconosce la lista e mostra il messaggio generico
|
||||
tradotto `error.invalid_request`.
|
||||
@@ -269,7 +265,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
`app/api/routes/admin.py:update_bug_report_status`,
|
||||
`app/api/routes/rounds.py:round_stream`, `app/main.py` ·
|
||||
*Test:* `tests/unit/test_admin.py`, `tests/unit/test_bug_reports.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* tutte le risposte della tabella
|
||||
hanno `detail` come stringa inglese (colonna "Oggi"), e il frontend la
|
||||
mostra così com'è.
|
||||
|
||||
@@ -334,7 +329,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
massimo 256 caratteri (oltre: `422 invalid_request`, prima di qualunque
|
||||
hash Argon2 e senza consumare i contatori di fallimento). Lo username è
|
||||
confrontato senza distinzione tra maiuscole e minuscole.
|
||||
*Stato:* da implementare (B-94) · *Oggi:* nessun vincolo di lunghezza.
|
||||
- **Risposta `200`:** `{"access_token": <JWT>, "address": <indirizzo>}`.
|
||||
- **Errori:** `422`; `429 rate_limited` (`params.retry_after_seconds`,
|
||||
arrotondato per eccesso al secondo); `401 invalid_credentials` —
|
||||
@@ -399,7 +393,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
*Codice:* `app/api/routes/users.py:change_password`,
|
||||
`app/auth/rate_limit.py:RateLimiter` ·
|
||||
*Test:* `tests/unit/test_users.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* nessuna lunghezza massima, nessun
|
||||
limite di frequenza (la password attuale si può provare senza limiti) e
|
||||
nessuna voce di audit.
|
||||
|
||||
@@ -609,7 +602,7 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
`app/rounds/events.py:RoundEventBroadcaster.subscribe`,
|
||||
`app/rounds/events.py:RoundEventBroadcaster.publish` ·
|
||||
*Test:* `tests/unit/test_round_events.py`
|
||||
*Stato:* da implementare (B-94, B-93) · *Oggi:* il `503` ha il corpo non
|
||||
*Stato:* da implementare (B-93), per la chiusura dei flussi all'arresto
|
||||
strutturato `{"detail": "too many concurrent update streams"}`, e un flusso
|
||||
aperto non viene chiuso all'arresto: uvicorn lo attende finché Docker non
|
||||
termina il processo.
|
||||
@@ -720,7 +713,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
|
||||
`app/api/routes/bug_reports.py:BugReportCreate`,
|
||||
`app/auth/rate_limit.py:RollingQuota` ·
|
||||
*Test:* `tests/unit/test_bug_reports.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* nessun limite di frequenza: un
|
||||
anonimo può inserire segnalazioni senza limite.
|
||||
|
||||
#### GET /bug-reports/mine
|
||||
@@ -841,7 +833,6 @@ dei parametri operativi in
|
||||
"balance_sats", "created_at"}`; `balance_sats` è il saldo confermato.
|
||||
*Codice:* `app/api/routes/admin.py:list_users` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* restituisce **tutti** gli utenti
|
||||
in una sola risposta, senza paginazione né limite.
|
||||
|
||||
#### GET /admin/users/{user_id}/privkey
|
||||
@@ -1029,7 +1020,7 @@ dei parametri operativi in
|
||||
|---|---|---|---|---|---|
|
||||
| `session_expired` | 401 | `app/auth/dependencies.py:get_current_user` | — | token illeggibile, scaduto, utente inesistente o token superato da un cambio/reset password | sì |
|
||||
| `invalid_credentials` | 401 | `app/auth/routes.py:login` | — | username inesistente o password sbagliata (risposta identica) | sì |
|
||||
| `rate_limited` | 429 | `app/auth/routes.py:register`, `app/auth/routes.py:login`; anche `app/api/routes/users.py:change_password` e `app/api/routes/bug_reports.py` *da implementare (B-94)* | `retry_after_seconds` | quota di registrazione, blocco per tentativi falliti, o quota di segnalazioni | sì |
|
||||
| `rate_limited` | 429 | `app/auth/routes.py:register`, `app/auth/routes.py:login`; anche `app/api/routes/users.py:change_password` e `app/api/routes/bug_reports.py` | `retry_after_seconds` | quota di registrazione, blocco per tentativi falliti, o quota di segnalazioni | sì |
|
||||
| `username_taken` | 409 | `app/auth/routes.py:register` | — | nome già usato (senza distinzione di maiuscole) | sì |
|
||||
| `derivation_index_conflict` | 409 | `app/auth/routes.py:register` | — | nessun indice di derivazione libero dopo 5 tentativi | sì |
|
||||
| `current_password_incorrect` | 401 | `app/api/routes/users.py:change_password` | — | password attuale errata | sì |
|
||||
@@ -1053,14 +1044,14 @@ dei parametri operativi in
|
||||
| `withdrawal_to_platform_address` | 400 | `app/withdrawals/service.py:request_withdrawal` | — | destinazione uguale a un indirizzo di montepremi, di riserva o delle commissioni | sì |
|
||||
| `service_busy` | 503 | `app/bets/service.py:place_bet`, `app/withdrawals/service.py:request_withdrawal`, `app/auth/routes.py:register` | — | il database non ha concesso il lock di scrittura entro il timeout; nulla è stato trasmesso, si può riprovare | sì |
|
||||
| `registration_unavailable` | 503 | `app/auth/routes.py:register` | — | registrazione sospesa finché il controllo degli indici di derivazione dopo un ripristino non è concluso o risolto ([registrazione-e-depositi.md](registrazione-e-depositi.md), B-90) | sì |
|
||||
| `not_authenticated` | 401 | dipendenza Bearer di FastAPI | — | header `Authorization` assente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `invalid_admin_token` | 403 | `app/api/routes/admin.py:require_admin` | — | token admin assente o errato — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `user_not_found` | 404 | `app/api/routes/admin.py` | — | utente inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `bug_report_not_found` | 404 | `app/api/routes/admin.py` | — | segnalazione inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `not_authenticated` | 401 | dipendenza Bearer di FastAPI | — | header `Authorization` assente | sì |
|
||||
| `invalid_admin_token` | 403 | `app/api/routes/admin.py:require_admin` | — | token admin assente o errato | sì |
|
||||
| `user_not_found` | 404 | `app/api/routes/admin.py` | — | utente inesistente | sì |
|
||||
| `bug_report_not_found` | 404 | `app/api/routes/admin.py` | — | segnalazione inesistente | sì |
|
||||
| `round_not_found` | 404 | `app/api/routes/rounds.py` (`GET /rounds/{id}/proof`) | — | round inesistente | sì |
|
||||
| `not_found` | 404 | gestore delle eccezioni HTTP in `app/main.py` | — | percorso inesistente — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `method_not_allowed` | 405 | gestore delle eccezioni HTTP in `app/main.py` | — | metodo non ammesso — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `too_many_streams` | 503 | `app/api/routes/rounds.py` (SSE) | — | tetto globale degli stream raggiunto — oggi `detail` stringa — *da implementare (B-94)* | no — da aggiungere |
|
||||
| `not_found` | 404 | gestore delle eccezioni HTTP in `app/main.py` | — | percorso inesistente | sì |
|
||||
| `method_not_allowed` | 405 | gestore delle eccezioni HTTP in `app/main.py` | — | metodo non ammesso | sì |
|
||||
| `too_many_streams` | 503 | `app/api/routes/rounds.py` (SSE) | — | tetto globale degli stream raggiunto | sì |
|
||||
|
||||
Due codici di `app/wallet/psbt_builder.py` — `winner_share_below_network_fee`
|
||||
e `commission_below_dust_limit` — riguardano solo la costruzione del
|
||||
|
||||
@@ -397,7 +397,6 @@ implementare» non esistono ancora nel codice (vedi [ARC-60] e [ARC-22]).
|
||||
`/app`.
|
||||
*Codice:* `app/main.py:index_page` · `app/main.py:admin_panel` ·
|
||||
`app/main.py:user_guide` · `app/main.py:report_bug_page`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* `Cache-Control` c'è **solo** sulle
|
||||
quattro pagine HTML; i file serviti da `StaticFiles` (e `/index.html`
|
||||
chiesto per nome) non ne hanno, e il browser può usare uno script vecchio
|
||||
con una pagina nuova.
|
||||
|
||||
+1
-2
@@ -275,7 +275,6 @@ flowchart LR
|
||||
sbagliata, nuova troppo corta) non scrive nulla. Nessuna logica rilegge la
|
||||
riga.
|
||||
*Codice:* `app/api/routes/users.py:change_password` · *Test:* —
|
||||
*Stato:* da implementare (B-94) · *Oggi:* il cambio password non scrive
|
||||
alcun evento.
|
||||
|
||||
- **[AUD-43]** `restore_gap_detected` — il controllo all'avvio ha trovato
|
||||
@@ -627,7 +626,7 @@ flowchart LR
|
||||
`app/rounds/scheduler.py:RoundScheduler._tick`,
|
||||
`app/rounds/confirmation.py:_on_payout_confirmed`,
|
||||
`app/tx/confirmation.py:poll_once`, `app/tx/broadcast.py:bump_fee`
|
||||
*Stato:* da implementare (B-92, B-94), per i due fatti che escono
|
||||
*Stato:* da implementare (B-92), per user_registered
|
||||
dall'elenco · *Oggi:* la registrazione e il cambio password riuscito non
|
||||
producono alcuna riga.
|
||||
|
||||
|
||||
+8
-22
@@ -151,10 +151,10 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
blocco per-utente li serializza, non li limita); il cambio password
|
||||
permette di provare password attuali senza ritardi a chi possiede già un
|
||||
token valido; le segnalazioni anonime possono riempire il database.
|
||||
- *Stato:* da chiudere in parte con B-94 (cambio password limitato e
|
||||
registrato, segnalazioni di bug limitate per IP, lunghezza massima di 256
|
||||
caratteri sui campi delle credenziali). Scommesse, prelievi ed endpoint
|
||||
admin restano senza limite di frequenza anche dopo il piano.
|
||||
- *Stato:* chiuso in parte da B-94 (cambio password limitato e registrato,
|
||||
segnalazioni di bug limitate per IP, lunghezza massima di 256 caratteri sui
|
||||
campi delle credenziali). Scommesse, prelievi ed endpoint admin restano
|
||||
senza limite di frequenza.
|
||||
- *Per chiuderlo:* un limitatore per utente e per IP sugli endpoint
|
||||
interessati, sullo stesso modello di `RateLimiter`/`RollingQuota`.
|
||||
|
||||
@@ -166,8 +166,8 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
- *Impatto:* dopo una compromissione non c'è traccia di quando e da dove sia
|
||||
avvenuto l'accesso.
|
||||
- *Stato:* da chiudere in parte con B-92 (`user_registered`, con indice di
|
||||
derivazione e indirizzo) e B-94 (`password_changed`). Login riusciti e
|
||||
falliti restano senza voce di audit dopo il piano.
|
||||
derivazione e indirizzo); chiusa da B-94 la parte `password_changed`. Login
|
||||
riusciti e falliti restano senza voce di audit dopo il piano.
|
||||
- *Per chiuderlo:* eventi di audit per questi casi (vedi
|
||||
[audit-log.md](audit-log.md)).
|
||||
|
||||
@@ -414,21 +414,7 @@ implementato il [piano delle correzioni](piano-correzioni.md).
|
||||
|
||||
*Codice:* `app/static/i18n.js:t`
|
||||
|
||||
- **[LIM-22]** **L'API di amministrazione e alcuni errori di contorno non usano
|
||||
il formato d'errore strutturato.** Gli endpoint `/admin/*` rispondono con un
|
||||
`detail` stringa (per esempio `"invalid admin token"`, `"user not found"`), e
|
||||
così il 503 del canale SSE oltre il tetto; è coerente con un pannello non
|
||||
tradotto, ma non è dichiarato altrove.
|
||||
- *Stato:* da chiudere con B-94.
|
||||
- *Per chiuderlo:* errori strutturati ovunque: `/admin/*`, il 503 del canale
|
||||
SSE e gli errori generati dal framework usano lo stesso formato
|
||||
`{code, message, params}` ([ADR-47](adr/ADR-47-l-api-parla-sola-lingua-risponde.md)).
|
||||
- ~~[LIM-22]~~ Rimosso: chiuso da B-94. Gli endpoint `/admin/*`, il `503` del canale SSE e gli errori del framework (`401`, `404`, `405`, `422`) rispondono con il formato strutturato `{code, message, params}` ([API-16](api.md#contratto-degli-errori), [ADR-47](adr/ADR-47-l-api-parla-sola-lingua-risponde.md)).
|
||||
|
||||
*Codice:* `app/api/routes/admin.py:require_admin` · `app/api/routes/rounds.py:round_stream`
|
||||
- ~~[LIM-23]~~ Rimosso: chiuso da B-94. `GET /admin/users` è paginato ([API-33](api.md)).
|
||||
|
||||
- **[LIM-23]** **`GET /admin/users` non ha limite né paginazione**, a
|
||||
differenza degli altri elenchi admin.
|
||||
- *Impatto:* con molti utenti la risposta cresce senza limite.
|
||||
- *Stato:* da chiudere con B-94 (paginazione dell'elenco utenti).
|
||||
|
||||
*Codice:* `app/api/routes/admin.py:list_users`
|
||||
@@ -123,7 +123,6 @@ il freno sui tentativi di login falliti è
|
||||
prima di qualunque verifica.
|
||||
*Codice:* `app/auth/routes.py:login`, `app/auth/routes.py:LoginRequest` ·
|
||||
*Test:* `tests/unit/test_auth.py`
|
||||
*Stato:* da implementare (B-94), per la lunghezza massima · *Oggi:* i campi
|
||||
del login non hanno limiti di lunghezza.
|
||||
- ~~[REG-10]~~ Assorbito da [SEC-30](sicurezza.md#limiti-di-frequenza):
|
||||
il freno sui login falliti (chiavi, soglie, azzeramento) non ha nulla di
|
||||
@@ -146,7 +145,6 @@ il freno sui tentativi di login falliti è
|
||||
e [api.md](api.md) vi rimandano.
|
||||
*Codice:* `app/api/routes/users.py:change_password` ·
|
||||
*Test:* `tests/unit/test_users.py`
|
||||
*Stato:* da implementare (B-94), per lunghezza massima, freno ed evento
|
||||
`password_changed` · *Oggi:* nessun limite di lunghezza, nessun freno sui
|
||||
tentativi, nessun evento di audit.
|
||||
- **[REG-13]** Non esiste recupero password: non viene raccolta alcuna email.
|
||||
|
||||
+4
-14
@@ -324,7 +324,6 @@ che ne è il proprietario; qui ci sono le regole di sicurezza.
|
||||
*Codice:* `app/auth/security.py:MIN_PASSWORD_LENGTH`, `app/auth/routes.py:RegisterRequest`,
|
||||
`app/auth/routes.py:LoginRequest`, `app/api/routes/users.py:ChangePasswordRequest` ·
|
||||
*Test:* `tests/unit/test_users.py`
|
||||
*Stato:* da implementare (B-94), per il massimo su login e cambio password ·
|
||||
*Oggi:* il massimo di 256 vale solo per la password della registrazione;
|
||||
i campi del login e del cambio password non hanno limite di lunghezza.
|
||||
|
||||
@@ -374,7 +373,6 @@ che ne è il proprietario; qui ci sono le regole di sicurezza.
|
||||
*Codice:* `app/auth/dependencies.py:get_current_user`, `app/api/routes/users.py:change_password`,
|
||||
`app/api/routes/admin.py:reset_user_password` ·
|
||||
*Test:* `tests/unit/test_users.py`, `tests/unit/test_security.py`, `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94), per `not_authenticated` · *Oggi:*
|
||||
l'header assente risponde 401 con il corpo non strutturato di FastAPI
|
||||
`{"detail": "Not authenticated"}`.
|
||||
|
||||
@@ -398,7 +396,6 @@ endpoint `/admin/*`, che richiedono l'header `X-Admin-Token`.
|
||||
`invalid_admin_token` ([API-08](api.md#convenzioni-generali)).
|
||||
*Codice:* `app/api/routes/admin.py:require_admin` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94), per il codice · *Oggi:* 403 con il corpo
|
||||
non strutturato `{"detail": "invalid admin token"}`.
|
||||
|
||||
- **[SEC-25]** Il token è **unico e condiviso**: non esiste un'identità per
|
||||
@@ -457,8 +454,8 @@ ogni riavvio e non funzionerebbero con più worker.
|
||||
| Login per username | `user:<username minuscolo>` | Dal 5° fallimento: blocco di 2s, raddoppiato a ogni fallimento successivo, massimo 300s. Il contatore si azzera dopo 900s senza fallimenti, o con un login riuscito | `POST /auth/login` |
|
||||
| Login per IP | `ip:<ip>` | Dal 20° fallimento: stessa progressione (2s → 300s), stesso decadimento di 900s. **Non** si azzera con un login riuscito | `POST /auth/login` |
|
||||
| Registrazione per IP | `ip:<ip>` | Al massimo 5 account creati per IP in una finestra mobile di 3600s. Contano solo gli account creati davvero | `POST /auth/register` |
|
||||
| Cambio password per utente *(B-94, da implementare)* | `user:<id utente>`, in un limitatore separato da quello del login | Dal 5° fallimento della password attuale: stessa progressione del login (2s → 300s), stesso decadimento di 900s; un cambio riuscito azzera il contatore | `POST /users/me/change-password` |
|
||||
| Segnalazioni di bug per IP *(B-94, da implementare)* | `ip:<ip>` | Al massimo 10 segnalazioni per IP in una finestra mobile di 3600s, anche da autenticati. Contano solo le segnalazioni salvate davvero | `POST /bug-reports` |
|
||||
| Cambio password per utente *(B-94)* | `user:<id utente>`, in un limitatore separato da quello del login | Dal 5° fallimento della password attuale: stessa progressione del login (2s → 300s), stesso decadimento di 900s; un cambio riuscito azzera il contatore | `POST /users/me/change-password` |
|
||||
| Segnalazioni di bug per IP *(B-94)* | `ip:<ip>` | Al massimo 10 segnalazioni per IP in una finestra mobile di 3600s, anche da autenticati. Contano solo le segnalazioni salvate davvero | `POST /bug-reports` |
|
||||
| Stream SSE per IP | IP | Al massimo 5 stream contemporanei per IP: il 6° chiude il più vecchio dello stesso IP | `GET /rounds/stream` |
|
||||
| Stream SSE globale | — | Al massimo 500 stream contemporanei: oltre, 503 | `GET /rounds/stream` |
|
||||
|
||||
@@ -493,8 +490,7 @@ ogni riavvio e non funzionerebbero con più worker.
|
||||
[API-21](api.md).
|
||||
*Codice:* `app/api/routes/users.py:change_password`,
|
||||
`app/auth/rate_limit.py:RateLimiter`, `app/auth/rate_limit.py:AuthRateLimiters` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-94) · *Oggi:* nessun freno: con un token valido
|
||||
*Test:* `tests/unit/test_abuse_limits.py`
|
||||
la password attuale si può provare senza limiti.
|
||||
|
||||
- **[SEC-55]** L'invio di segnalazioni di bug è una **quota** per IP
|
||||
@@ -506,8 +502,7 @@ ogni riavvio e non funzionerebbero con più worker.
|
||||
vecchia ([API-28](api.md)).
|
||||
*Codice:* `app/api/routes/bug_reports.py:create_bug_report`,
|
||||
`app/auth/rate_limit.py:RollingQuota` ·
|
||||
*Test:* —
|
||||
*Stato:* da implementare (B-94) · *Oggi:* nessun limite: un anonimo può
|
||||
*Test:* `tests/unit/test_abuse_limits.py`
|
||||
inserire segnalazioni senza limite, fino a riempire il database.
|
||||
|
||||
- **[SEC-32]** La memoria dei limitatori è limitata: le voci esaurite vengono
|
||||
@@ -524,7 +519,6 @@ ogni riavvio e non funzionerebbero con più worker.
|
||||
*Codice:* `app/rounds/events.py:RoundEventBroadcaster.subscribe`,
|
||||
`app/api/routes/rounds.py:round_stream` ·
|
||||
*Test:* `tests/unit/test_round_events.py`
|
||||
*Stato:* da implementare (B-94), per il codice `too_many_streams` ·
|
||||
*Oggi:* il 503 ha il corpo non strutturato
|
||||
`{"detail": "too many concurrent update streams"}`.
|
||||
|
||||
@@ -536,7 +530,6 @@ ogni riavvio e non funzionerebbero con più worker.
|
||||
che non è un limite di frequenza ([LIM-07](limiti-noti.md)).
|
||||
*Codice:* `app/api/routes/bets.py`, `app/api/routes/withdrawals.py`,
|
||||
`app/api/routes/admin.py:require_admin`
|
||||
*Stato:* da implementare (B-94) · *Oggi:* anche il cambio password e
|
||||
l'invio di segnalazioni di bug sono senza limite ([SEC-54], [SEC-55]).
|
||||
|
||||
## Indirizzo IP del client
|
||||
@@ -667,7 +660,6 @@ a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in
|
||||
chiamate all'API solo dalla stessa origine.
|
||||
*Codice:* `app/main.py:index_page`, `app/main.py:admin_panel`,
|
||||
`app/main.py:user_guide`, `app/main.py:report_bug_page`, `app/main.py` (`app.mount`)
|
||||
*Stato:* da implementare (B-94), per i file statici · *Oggi:* i file
|
||||
serviti dal mount di `app/static/` non hanno `Cache-Control`, solo
|
||||
`ETag`/`Last-Modified`, e il browser può riusare uno script vecchio con
|
||||
una pagina nuova.
|
||||
@@ -687,7 +679,6 @@ a sé. L'unica risorsa esterna ammessa è Google Fonts (l'`@import` in
|
||||
([API-16](api.md#contratto-degli-errori)) e non ripete il valore ricevuto,
|
||||
così una password troppo lunga non torna indietro in chiaro.
|
||||
*Codice:* `app/main.py:log_unhandled_exception`, `app/main.py`
|
||||
*Stato:* da implementare (B-94), per gli errori diversi dal 500 ·
|
||||
*Oggi:* 401 senza header, 403 admin, 404/405 del framework e 503 dell'SSE
|
||||
hanno `detail` come stringa, e il 422 di validazione ripete nel campo
|
||||
`input` il valore ricevuto.
|
||||
@@ -734,7 +725,6 @@ di discrezione, non una protezione: la pagina è pubblica, gli endpoint no.
|
||||
`app/api/routes/admin.py:list_pending_transactions`, `app/api/routes/admin.py:list_bug_reports`,
|
||||
`app/api/routes/admin.py:list_users` ·
|
||||
*Test:* `tests/unit/test_admin.py`
|
||||
*Stato:* da implementare (B-94), per l'elenco utenti · *Oggi:*
|
||||
`GET /admin/users` restituisce tutti gli utenti in una sola risposta, senza
|
||||
limite né paginazione.
|
||||
|
||||
|
||||
+4
-4
@@ -242,7 +242,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
|
||||
| B-91 | Container eseguito come root, permessi larghi, dipendenze e immagini non fissate | Alta | *da registrare* | `test_container_hardening.py`, `test_config.py` | SEC, OPS |
|
||||
| B-92 | Nessuna verifica di salute o prontezza, stato Electrum invisibile, audit non filtrabile, log incompleti | Media | **aperto** | — | OPS, AUD, API |
|
||||
| B-93 | Deploy senza arresto ordinato, rollback impossibile, `SITE_ADDRESS` che torna a `localhost` | Media | **aperto** | — | OPS |
|
||||
| B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | **aperto** | — | SEC, API |
|
||||
| B-94 | Cambio password, login e bug report senza limiti adeguati; errori non strutturati in alcune risposte | Media | *da registrare* | `test_abuse_limits.py` | SEC, API |
|
||||
| B-95 | Costo della scommessa e `fee_address` letti dalla configurazione corrente invece che fotografati all'apertura | Media | *da registrare* | `test_round_snapshot.py`, `test_admin.py`, `test_rounds_route.py`, `test_scheduler.py` | RND, BET |
|
||||
| B-96 | Incoerenze minori di stato (prelievo ridotto sotto il minimo, importi di un pagamento abbandonato, audit mancanti, attesa in `closing` senza segnali, …) | Media | **aperto** | — | TX, BET, WDR, DEP |
|
||||
| B-97 | Le migrazioni possono eliminare in silenzio gli indici su espressione | Media | *da registrare* | `test_migrations.py` | DAT |
|
||||
@@ -254,8 +254,8 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
|
||||
|
||||
- **Corretti:** B-01 … B-72, tranne B-16, che è chiuso nella sostanza (il
|
||||
crash è eliminato e `/guida` serve un segnaposto) ma lascia la guida vera
|
||||
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-90, B-91, B-95, B-98, B-99, B-100.
|
||||
- **Aperti:** dalla revisione del 2026-09-28, B-92, B-93, B-94 e B-96 (0 critici,
|
||||
0 alti, 4 medi), da implementare nell'ordine di
|
||||
come limite accettato; dalla revisione del 2026-09-28: B-88, B-75, B-97, B-80, B-84, B-76, B-77, B-78, B-83, B-74, B-85, B-86, B-73, B-81, B-79, B-82, B-87, B-89, B-90, B-91, B-94, B-95, B-98, B-99, B-100.
|
||||
- **Aperti:** dalla revisione del 2026-09-28, B-92, B-93 e B-96 (0 critici,
|
||||
0 alti, 3 medi), da implementare nell'ordine di
|
||||
[piano-correzioni.md](piano-correzioni.md).
|
||||
- **Prossimo numero libero:** `B-101`.
|
||||
@@ -0,0 +1,183 @@
|
||||
"""B-94 (SEC-54, SEC-55, API-09, API-15, API-16, API-19, API-21, API-28, API-33, REG-12):
|
||||
credential length bounds, the password-change brake and audit, the bug-report quota, paged
|
||||
user list, structured errors everywhere and revalidated static files."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
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, "admin_token", "admin-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.db.models # noqa: F401
|
||||
import app.wallet.hd as hd
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db import base as db_base
|
||||
from app.db import session as db_session
|
||||
|
||||
hd._account_key = None
|
||||
hd.generate_master_key()
|
||||
db_base.engine = create_async_engine(settings.database_url)
|
||||
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
|
||||
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.electrum.listener import ElectrumListener
|
||||
from app.main import app
|
||||
|
||||
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
|
||||
app.state.auth_rate_limiters = None
|
||||
if hasattr(app.state, "auth_rate_limiters"):
|
||||
del app.state.auth_rate_limiters # fresh throttle state per test
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
yield ac
|
||||
await db_base.engine.dispose()
|
||||
|
||||
|
||||
ADMIN = {"X-Admin-Token": "admin-secret"}
|
||||
|
||||
|
||||
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 {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||
|
||||
|
||||
def _code(resp):
|
||||
return resp.json()["detail"]["code"]
|
||||
|
||||
|
||||
# --- field lengths (API-19, REG-12) and the 422 shape (API-15) ---------------------
|
||||
|
||||
|
||||
async def test_login_fields_are_bounded_and_the_value_is_not_echoed(client):
|
||||
resp = await client.post("/auth/login", json={"username": "a", "password": "secret-" + "x" * 300})
|
||||
assert resp.status_code == 422
|
||||
assert _code(resp) == "invalid_request"
|
||||
errors = resp.json()["detail"]["params"]["errors"]
|
||||
assert errors[0]["loc"] == ["body", "password"] and set(errors[0]) == {"loc", "msg", "type"}
|
||||
assert "secret-" not in resp.text
|
||||
|
||||
|
||||
async def test_change_password_fields_are_bounded(client):
|
||||
headers = await _register(client)
|
||||
resp = await client.post(
|
||||
"/users/me/change-password",
|
||||
headers=headers,
|
||||
json={"current_password": "original-password", "new_password": "x" * 257},
|
||||
)
|
||||
assert resp.status_code == 422 and _code(resp) == "invalid_request"
|
||||
|
||||
|
||||
# --- the password-change brake (SEC-54) and its audit (AUD-42) ---------------------
|
||||
|
||||
|
||||
async def test_the_fifth_wrong_current_password_locks_even_the_right_one_out(client):
|
||||
headers = await _register(client)
|
||||
body = {"current_password": "wrong-password", "new_password": "brand-new-password"}
|
||||
for _ in range(5):
|
||||
assert (await client.post("/users/me/change-password", headers=headers, json=body)).status_code == 401
|
||||
resp = await client.post("/users/me/change-password", headers=headers, json=body)
|
||||
assert resp.status_code == 429 and _code(resp) == "rate_limited"
|
||||
right = {"current_password": "original-password", "new_password": "brand-new-password"}
|
||||
locked = await client.post("/users/me/change-password", headers=headers, json=right)
|
||||
assert locked.status_code == 429 and locked.json()["detail"]["params"]["retry_after_seconds"] >= 1
|
||||
|
||||
|
||||
async def test_a_successful_change_is_audited_and_clears_the_brake(client):
|
||||
from app.db import base as db_base
|
||||
from app.db.models import AuditLog
|
||||
|
||||
headers = await _register(client)
|
||||
bad = {"current_password": "wrong-password", "new_password": "brand-new-password"}
|
||||
for _ in range(3):
|
||||
await client.post("/users/me/change-password", headers=headers, json=bad)
|
||||
ok = {"current_password": "original-password", "new_password": "brand-new-password"}
|
||||
assert (await client.post("/users/me/change-password", headers=headers, json=ok)).status_code == 200
|
||||
async with db_base.AsyncSessionLocal() as session:
|
||||
rows = (await session.execute(select(AuditLog).where(AuditLog.event_type == "password_changed"))).scalars().all()
|
||||
assert len(rows) == 1 and json.loads(rows[0].payload_json) == {"user_id": rows[0].user_id}
|
||||
assert "password" not in rows[0].payload_json.replace("user_id", "")
|
||||
|
||||
|
||||
# --- bug reports (SEC-55) ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_11th_bug_report_from_one_ip_waits_but_an_invalid_one_costs_nothing(client):
|
||||
for _ in range(3):
|
||||
assert (await client.post("/bug-reports", json={"description": " "})).status_code == 422
|
||||
for i in range(10):
|
||||
assert (await client.post("/bug-reports", json={"description": f"bug {i}"})).status_code == 201
|
||||
resp = await client.post("/bug-reports", json={"description": "one too many"})
|
||||
assert resp.status_code == 429 and _code(resp) == "rate_limited"
|
||||
assert 3000 < resp.json()["detail"]["params"]["retry_after_seconds"] <= 3601
|
||||
|
||||
|
||||
# --- paged user list (API-33) ------------------------------------------------------
|
||||
|
||||
|
||||
async def test_admin_users_are_paged_by_id(client):
|
||||
for i in range(5):
|
||||
await _register(client, username=f"user{i}")
|
||||
first = (await client.get("/admin/users", headers=ADMIN, params={"limit": 2})).json()
|
||||
second = (await client.get("/admin/users", headers=ADMIN, params={"limit": 2, "after_id": first[-1]["id"]})).json()
|
||||
last = (await client.get("/admin/users", headers=ADMIN, params={"limit": 2, "after_id": second[-1]["id"]})).json()
|
||||
assert [u["username"] for u in first + second + last] == [f"user{i}" for i in range(5)]
|
||||
assert len(last) == 1
|
||||
assert (await client.get("/admin/users", headers=ADMIN, params={"limit": 501})).status_code == 422
|
||||
|
||||
|
||||
# --- structured errors everywhere (API-16) -----------------------------------------
|
||||
|
||||
|
||||
async def test_admin_errors_are_structured(client):
|
||||
wrong = await client.get("/admin/users", headers={"X-Admin-Token": "nope"})
|
||||
assert wrong.status_code == 403 and _code(wrong) == "invalid_admin_token"
|
||||
missing = await client.post("/admin/users/999/reset-password", headers=ADMIN)
|
||||
assert missing.status_code == 404 and _code(missing) == "user_not_found"
|
||||
report = await client.post("/admin/bug-reports/999/status", headers=ADMIN, json={"status": "read"})
|
||||
assert report.status_code == 404 and _code(report) == "bug_report_not_found"
|
||||
|
||||
|
||||
async def test_framework_errors_are_structured(client):
|
||||
absent = await client.get("/users/me")
|
||||
assert absent.status_code == 401 and _code(absent) == "not_authenticated"
|
||||
assert absent.headers["www-authenticate"] == "Bearer"
|
||||
assert _code(await client.get("/no/such/path")) == "not_found"
|
||||
wrong_method = await client.put("/rounds/current")
|
||||
assert _code(wrong_method) in {"method_not_allowed", "not_found"}
|
||||
assert (await client.get("/users/me", headers={"Authorization": "Bearer junk"})).json()["detail"]["code"] == "session_expired"
|
||||
|
||||
|
||||
async def test_the_sse_cap_answers_in_the_structured_shape(client, monkeypatch):
|
||||
from app.api.routes import rounds
|
||||
from app.rounds.events import RoundEventCapacityError
|
||||
|
||||
def full(_ip):
|
||||
raise RoundEventCapacityError()
|
||||
|
||||
monkeypatch.setattr(rounds.broadcaster, "subscribe", full)
|
||||
resp = await client.get("/rounds/stream")
|
||||
assert resp.status_code == 503 and _code(resp) == "too_many_streams"
|
||||
|
||||
|
||||
# --- static files (API-09) ---------------------------------------------------------
|
||||
|
||||
|
||||
async def test_static_files_are_revalidated_on_every_use(client):
|
||||
resp = await client.get("/app.js")
|
||||
assert resp.status_code == 200 and resp.headers["cache-control"] == "no-cache"
|
||||
assert resp.headers.get("etag")
|
||||
Reference in new issue
Block a user