diff --git a/BUGS.md b/BUGS.md
index d18fb2b..bae66a0 100644
--- a/BUGS.md
+++ b/BUGS.md
@@ -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). A finding is removed from this file once it is fixed, and
+(B-93). 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.
@@ -35,16 +35,6 @@ decisions, see `docs/limiti-noti.md`):
## Medium
-### B-92 — No health, readiness or real audit browsing
-
-`/health` always answers ok; nothing exposes Electrum state; the audit endpoint
-returns only the latest 500 rows unfiltered; registration writes no audit row; the
-access log records Caddy's IP; app logs go only to the file.
-
-Fix: `GET /health/ready` (Electrum connected, tip age < 1800s, scheduler heartbeat <
-30s) used as the container healthcheck; Electrum status in `/admin`; audit filters by
-event type, round, user and time, with pagination; `user_registered` audit row with
-index and address; uvicorn proxy headers from Caddy only; logs also to stdout.
### B-93 — Deploys and rollbacks
diff --git a/CLAUDE.md b/CLAUDE.md
index f3ad4d1..ecee9b9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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 — 749 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 — 758 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 749 tests
+python -m pytest # all 758 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
```
diff --git a/Dockerfile b/Dockerfile
index d777528..71f0ed1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -22,4 +22,4 @@ USER 1000:1000
EXPOSE 8123
# umask 077: the database, its WAL files and the logs are created readable by the app user only.
-CMD ["/bin/sh", "-c", "umask 077; test -f \"$MASTER_KEY_PATH\" || { echo \"ERROR: master key not found at $MASTER_KEY_PATH -- run: docker compose run --rm app python scripts/generate_master_key.py\" >&2; exit 1; }; alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8123"]
+CMD ["/bin/sh", "-c", "umask 077; test -f \"$MASTER_KEY_PATH\" || { echo \"ERROR: master key not found at $MASTER_KEY_PATH -- run: docker compose run --rm app python scripts/generate_master_key.py\" >&2; exit 1; }; alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8123 --proxy-headers --forwarded-allow-ips \"${FORWARDED_ALLOW_IPS:-127.0.0.1}\""]
diff --git a/README.md b/README.md
index df4c3a3..47e8757 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,7 @@ python -m pytest # all tests
python -m pytest tests/unit/test_hd.py # one file
```
-749 unit tests cover HD derivation, PSBT building, the Electrum client, bets,
+758 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
diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py
index 0424bb3..f6d683c 100644
--- a/app/api/routes/admin.py
+++ b/app/api/routes/admin.py
@@ -1,7 +1,8 @@
import json
import secrets
+from datetime import datetime, timezone
-from fastapi import APIRouter, Depends, Header, Query, status
+from fastapi import APIRouter, Depends, Header, Query, Request, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -369,13 +370,54 @@ class AdminAuditLogResponse(BaseModel):
created_at: str
+def _parse_instant(name: str, value: str | None) -> datetime | None:
+ """An ISO 8601 instant with an offset, as UTC; anything else is a 422 (API-37)."""
+ if value is None:
+ return None
+ try:
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ parsed = None
+ if parsed is None or parsed.tzinfo is None:
+ raise http_error(
+ status.HTTP_422_UNPROCESSABLE_CONTENT,
+ "invalid_request",
+ "invalid request",
+ errors=[{"loc": ["query", name], "msg": "an ISO 8601 instant with an offset is required", "type": "value_error"}],
+ )
+ return parsed.astimezone(timezone.utc)
+
+
@router.get(
"/audit-log", response_model=list[AdminAuditLogResponse], dependencies=[Depends(require_admin)]
)
async def list_audit_log(
- session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500)
+ session: AsyncSession = Depends(get_session),
+ limit: int = Query(default=200, ge=1, le=500),
+ before_id: int | None = Query(default=None),
+ event_type: list[str] | None = Query(default=None),
+ round_id: int | None = Query(default=None),
+ user_id: int | None = Query(default=None),
+ from_: str | None = Query(default=None, alias="from"),
+ to: str | None = Query(default=None),
) -> list[AdminAuditLogResponse]:
- entries = (await session.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(limit))).all()
+ """B-92 (API-37): newest first, a page at a time, every filter optional and combined with
+ "and". The next page asks for `before_id` equal to the last id received."""
+ query = select(AuditLog).order_by(AuditLog.id.desc()).limit(limit)
+ if before_id is not None:
+ query = query.where(AuditLog.id < before_id)
+ if event_type:
+ query = query.where(AuditLog.event_type.in_(event_type))
+ if round_id is not None:
+ query = query.where(AuditLog.round_id == round_id)
+ if user_id is not None:
+ query = query.where(AuditLog.user_id == user_id)
+ start, end = _parse_instant("from", from_), _parse_instant("to", to)
+ if start is not None:
+ query = query.where(AuditLog.created_at >= start)
+ if end is not None:
+ query = query.where(AuditLog.created_at < end)
+ entries = (await session.scalars(query)).all()
return [
AdminAuditLogResponse(
id=e.id,
@@ -517,3 +559,29 @@ async def update_bug_report_status(
username = user.username if user is not None else None
return _bug_report_response(report, username)
+
+
+class AdminElectrumResponse(BaseModel):
+ connected: bool
+ endpoint: str | None
+ tip_height: int
+ last_header_age_seconds: int | None
+ fallback_count: int
+ quorum_enforced: bool
+ last_rotation: dict | None
+
+
+@router.get("/electrum", response_model=AdminElectrumResponse, dependencies=[Depends(require_admin)])
+async def electrum_status(request: Request) -> AdminElectrumResponse:
+ """B-92 (API-47, ELC-29): the connection to the network, for the panel's indicator."""
+ listener = request.app.state.electrum_listener
+ connected = listener.client is not None
+ return AdminElectrumResponse(
+ connected=connected,
+ endpoint=str(listener.current_endpoint) if connected and listener.current_endpoint else None,
+ tip_height=listener.tip_height,
+ last_header_age_seconds=listener.last_header_age_seconds(),
+ fallback_count=listener.fallback_count,
+ quorum_enforced=listener.quorum_enforced,
+ last_rotation=listener.last_rotation,
+ )
diff --git a/app/auth/routes.py b/app/auth/routes.py
index c734c63..eecf48d 100644
--- a/app/auth/routes.py
+++ b/app/auth/routes.py
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.client_ip import client_ip as _client_ip
from app.api.errors import http_error
+from app.audit.log import write_audit_log
from app.auth.rate_limit import AuthRateLimiters
from app.auth.security import (
MIN_PASSWORD_LENGTH,
@@ -121,6 +122,15 @@ async def register(
)
session.add(user)
try:
+ # B-92 (AUD-41, REG-16): in the same transaction as the user, so after a restore the log
+ # says which derivation indexes were already assigned.
+ await session.flush()
+ await write_audit_log(
+ session,
+ "user_registered",
+ {"derivation_index": next_index, "address": address},
+ user_id=user.id,
+ )
await session.commit()
except IntegrityError as exc:
await session.rollback()
diff --git a/app/electrum/listener.py b/app/electrum/listener.py
index db7b435..20e67ea 100644
--- a/app/electrum/listener.py
+++ b/app/electrum/listener.py
@@ -1,6 +1,7 @@
import asyncio
import logging
import time
+from datetime import datetime, timezone
from collections import Counter
from collections.abc import Callable
@@ -79,6 +80,14 @@ _RESTORE_GAP_WINDOW = 20
_MAX_HEADERS_PER_REQUEST = 2016
+def _rotation_reason(exc: BaseException) -> str:
+ if isinstance(exc, ServerStalledError):
+ return "stalled_tip"
+ if isinstance(exc, HeaderValidationError):
+ return "invalid_header"
+ return "error"
+
+
class ServerStalledError(Exception):
"""The active server stopped following the chain while others report a higher tip
(B-86, ELC-21). Ends the session like a dropped connection, so run() rotates."""
@@ -119,6 +128,9 @@ class ElectrumListener:
self._allow_no_quorum = allow_no_quorum
self._endpoint_index = 0
self._restore_checked = False
+ # B-92 (API-47): why and when the listener last moved on to another server.
+ self.last_rotation: dict | None = None
+ self._end_reason: str | None = None
self._refresh_locks: dict[int, asyncio.Lock] = {}
self._scripthash_to_user: dict[str, int] = {}
# Retains address_for_new_user's fire-and-forget subscribe task so it
@@ -144,6 +156,18 @@ class ElectrumListener:
# Users with a refresh already running because of a new block (see _consume_headers).
self._block_refreshes: set[int] = set()
+ def last_header_age_seconds(self) -> int | None:
+ """B-92 (ELC-29): seconds since a header was last accepted, None before the first."""
+ return None if self._last_header_at is None else int(_monotonic() - self._last_header_at)
+
+ @property
+ def fallback_count(self) -> int:
+ return max(len(self._endpoints) - 1, 0)
+
+ @property
+ def quorum_enforced(self) -> bool:
+ return not self._allow_no_quorum
+
@property
def current_endpoint(self) -> ElectrumEndpoint | None:
"""Which server the next (or current) session uses — for logging and for
@@ -190,11 +214,13 @@ class ElectrumListener:
logger.error("no Electrum endpoints configured; listener idle")
return
connected = False
+ self._end_reason = None
try:
connected = await self._run_once(endpoint)
except asyncio.CancelledError:
raise
- except Exception:
+ except Exception as exc:
+ self._end_reason = _rotation_reason(exc)
logger.exception("Electrum session on %s failed", endpoint)
finally:
self.client = None
@@ -203,6 +229,11 @@ class ElectrumListener:
# server that just dropped us has no claim on being tried first again.
if len(self._endpoints) > 1:
self._endpoint_index = (self._endpoint_index + 1) % len(self._endpoints)
+ self.last_rotation = {
+ "reason": self._end_reason or ("connection_closed" if connected else "unreachable"),
+ "from": str(endpoint),
+ "at": datetime.now(timezone.utc).isoformat(),
+ }
if connected:
# We did reach a server, so the network is up — don't let an earlier
@@ -294,6 +325,7 @@ class ElectrumListener:
for task in done:
exc = task.exception()
if exc is not None:
+ self._end_reason = _rotation_reason(exc)
logger.warning("Electrum session on %s ending: %r", endpoint, exc)
finally:
self.client = None
diff --git a/app/logging_config.py b/app/logging_config.py
index c872f1c..66ee4d1 100644
--- a/app/logging_config.py
+++ b/app/logging_config.py
@@ -1,4 +1,5 @@
import logging
+import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
@@ -16,9 +17,16 @@ def setup_logging() -> None:
handler = RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5)
handler.setFormatter(logging.Formatter(_FORMAT))
+ # B-92 (ARC-09): also on stdout, so `docker compose logs app` shows the app's own messages
+ # (scheduler, listener, reconcilers) next to uvicorn's and the startup output. Only on the
+ # root logger: uvicorn's loggers already print to the console by themselves.
+ console = logging.StreamHandler(sys.stdout)
+ console.setFormatter(logging.Formatter(_FORMAT))
+
root = logging.getLogger()
root.setLevel(logging.INFO)
root.addHandler(handler)
+ root.addHandler(console)
# "uvicorn.error"/"uvicorn.access" have propagate=False in uvicorn's default
# logging config, so they never reach the root handler above and need their
diff --git a/app/main.py b/app/main.py
index 5c09314..ec9fcbf 100644
--- a/app/main.py
+++ b/app/main.py
@@ -72,6 +72,7 @@ async def lifespan(app: FastAPI):
app.state.user_locks = UserLocks()
scheduler = RoundScheduler(AsyncSessionLocal, listener)
+ app.state.scheduler = scheduler # B-92: /health/ready reads its heartbeat
poller = ConfirmationPoller(AsyncSessionLocal, lambda: listener.client, network=listener)
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client, network=listener)
# Resolves in-flight transactions against the chain — the piece that lets the
@@ -158,6 +159,35 @@ async def health() -> dict[str, str]:
return {"status": "ok"}
+# B-92 (API-46, ELC-29, ARC-61): the header age is generous on purpose — with 120 s blocks, 360 s
+# of silence happens by chance about 5% of the time (e^-3), and a healthcheck must not flap on that.
+READY_MAX_HEADER_AGE_SECONDS = 1800
+READY_MAX_SCHEDULER_HEARTBEAT_SECONDS = 30
+
+
+@app.get("/health/ready", include_in_schema=False)
+async def health_ready() -> JSONResponse:
+ listener = getattr(app.state, "electrum_listener", None)
+ scheduler = getattr(app.state, "scheduler", None)
+ header_age = listener.last_header_age_seconds() if listener is not None else None
+ heartbeat_age = scheduler.heartbeat_age_seconds() if scheduler is not None else None
+ checks = {
+ "electrum_connected": listener is not None and listener.client is not None,
+ "last_header_age_seconds": header_age,
+ "scheduler_heartbeat_age_seconds": heartbeat_age,
+ }
+ ready = (
+ checks["electrum_connected"]
+ and header_age is not None
+ and header_age < READY_MAX_HEADER_AGE_SECONDS
+ and heartbeat_age is not None
+ and heartbeat_age < READY_MAX_SCHEDULER_HEARTBEAT_SECONDS
+ )
+ return JSONResponse(
+ status_code=200 if ready else 503, content={"status": "ready" if ready else "not_ready", "checks": checks}
+ )
+
+
_NO_STORE_HEADERS = {"Cache-Control": "no-store"}
diff --git a/app/rounds/scheduler.py b/app/rounds/scheduler.py
index ae3ded5..b06c8a8 100644
--- a/app/rounds/scheduler.py
+++ b/app/rounds/scheduler.py
@@ -1,4 +1,5 @@
import asyncio
+import time
import logging
from datetime import datetime, timedelta, timezone
@@ -62,9 +63,16 @@ class RoundScheduler:
def __init__(self, session_factory: async_sessionmaker, listener: ElectrumListener):
self._session_factory = session_factory
self._listener = listener
+ # B-92 (ARC-61): monotonic time of the last tick or draw-wait iteration, read by
+ # /health/ready. The draw wait runs inside a tick and lasts several blocks, so it beats too.
+ self.last_heartbeat: float | None = None
+
+ def heartbeat_age_seconds(self) -> int | None:
+ return None if self.last_heartbeat is None else int(time.monotonic() - self.last_heartbeat)
async def run(self) -> None:
while True:
+ self.last_heartbeat = time.monotonic()
try:
await self._tick()
except asyncio.CancelledError:
@@ -409,6 +417,7 @@ class RoundScheduler:
reported_hashes: set[str | None] = set()
while True:
+ self.last_heartbeat = time.monotonic()
now = datetime.now(timezone.utc)
tip = self._listener.tip_height
if tip >= required_tip:
diff --git a/app/static/admin.html b/app/static/admin.html
index 406c78d..f17da5c 100644
--- a/app/static/admin.html
+++ b/app/static/admin.html
@@ -37,6 +37,7 @@
Connessione…Blocco —
+ Electrum —
@@ -153,7 +154,17 @@
Audit log
-
Ultimi eventi registrati dal sistema (config, bet, payout, accessi a chiavi private, ecc.).
+
Eventi registrati dal sistema (config, bet, payout, accessi a chiavi private, ecc.), dal più recente. I filtri si combinano; "Carica altri" prosegue dall'ultimo evento mostrato.
+
+
+
+
+
+
+
+
+
+
@@ -164,6 +175,7 @@
+
diff --git a/app/static/admin.js b/app/static/admin.js
index 5b08c2f..dbcf59e 100644
--- a/app/static/admin.js
+++ b/app/static/admin.js
@@ -76,6 +76,26 @@ async function refreshChainStatus() {
} catch (e) {
// leave the last-known status on screen rather than blanking it out
}
+ refreshElectrumStatus();
+}
+
+// B-92 (API-47): which server the platform is talking to and how fresh its last block is.
+async function refreshElectrumStatus() {
+ const el = document.getElementById('electrum-status');
+ if (!adminToken) return;
+ try {
+ const s = await callAdmin('GET', '/admin/electrum');
+ const age = s.last_header_age_seconds;
+ el.textContent = s.connected
+ ? `Electrum ${s.endpoint}${age != null ? ' · ultimo blocco ' + age + 's fa' : ''}`
+ : 'Electrum non connesso';
+ el.title = s.last_rotation
+ ? `Ultimo cambio server: ${s.last_rotation.from} (${s.last_rotation.reason}) alle ${fmtDate(s.last_rotation.at)}`
+ : '';
+ if (!s.quorum_enforced) el.title += ' · quorum non imposto (ALLOW_NO_QUORUM)';
+ } catch (e) {
+ // keep the last text
+ }
}
function startChainStatusPolling() {
@@ -90,7 +110,7 @@ function stopChainStatusPolling() {
}
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit', 'bugreports'];
-const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog, bugreports: loadBugReports };
+const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: () => loadAuditLog(), bugreports: loadBugReports };
let currentAdminView = 'parametri';
function switchView(name) {
@@ -345,11 +365,29 @@ async function loadPending() {
}
}
-async function loadAuditLog() {
+let auditLastId = null;
+const AUDIT_PAGE = 200;
+
+async function loadAuditLog(more = false) {
try {
- const entries = await callAdmin('GET', '/admin/audit-log');
+ // B-92 (API-37): filters combine; "more" continues from the last id shown.
+ const params = new URLSearchParams({ limit: String(AUDIT_PAGE) });
+ const type = document.getElementById('audit-f-type').value.trim();
+ if (type) params.append('event_type', type);
+ for (const [id, key] of [['audit-f-round', 'round_id'], ['audit-f-user', 'user_id']]) {
+ const v = document.getElementById(id).value.trim();
+ if (v) params.append(key, v);
+ }
+ for (const [id, key] of [['audit-f-from', 'from'], ['audit-f-to', 'to']]) {
+ const v = document.getElementById(id).value;
+ if (v) params.append(key, new Date(v).toISOString());
+ }
+ if (more === true && auditLastId !== null) params.append('before_id', String(auditLastId));
+ const entries = await callAdmin('GET', '/admin/audit-log?' + params);
+ auditLastId = entries.length ? entries[entries.length - 1].id : auditLastId;
+ document.getElementById('audit-more').classList.toggle('hidden', entries.length < AUDIT_PAGE);
const tbody = document.getElementById('audit-tbody');
- tbody.innerHTML = entries.map((e) => `
+ const rows = entries.map((e) => `
${e.id}
${escapeHtml(e.event_type)}
@@ -358,7 +396,9 @@ async function loadAuditLog() {
';
} catch (e) {
toast('Errore nel caricamento audit log: ' + e.message, 'error');
}
diff --git a/docker-compose.yml b/docker-compose.yml
index e22b5a3..b08f5ce 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -20,6 +20,9 @@ services:
- DATABASE_URL=sqlite+aiosqlite:////app/db_data/plm_lottery.db
- MASTER_KEY_PATH=/app/key_data/master.xprv.enc
- XPRV_ENCRYPTION_KEY_FILE=/run/secrets/xprv_encryption_key
+ # B-92 (ARC-02): uvicorn trusts X-Forwarded-For from Caddy's fixed address only, so the access
+ # log records the client's IP instead of Caddy's.
+ - FORWARDED_ALLOW_IPS=172.28.0.10
secrets:
- xprv_encryption_key
volumes:
@@ -28,6 +31,18 @@ services:
- ./data/logs:/app/logs
expose:
- "8123"
+ # B-92 (ARC-61): ready = Electrum connected, a recent header, a live scheduler. Docker does not
+ # restart an unhealthy container (the process exiting does); the state is for monitoring.
+ healthcheck:
+ test:
+ - CMD
+ - python
+ - -c
+ - "import urllib.request as u; u.urlopen('http://127.0.0.1:8123/health/ready', timeout=4)"
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 60s
depends_on:
data-permissions:
condition: service_completed_successfully
@@ -44,9 +59,19 @@ services:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
+ networks:
+ default:
+ ipv4_address: 172.28.0.10
depends_on:
- app
+# A fixed subnet and a fixed address for Caddy, so uvicorn can trust exactly that one proxy (ARC-02).
+networks:
+ default:
+ ipam:
+ config:
+ - subnet: 172.28.0.0/24
+
# The Fernet key that protects the master key lives in its own file, outside ./data (a backup of
# the data does not carry both) and in no environment variable (SEC-53).
secrets:
diff --git a/docs/api.md b/docs/api.md
index 6b0f47f..2db51fd 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -50,9 +50,9 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
| `GET` | `/admin/pending-transactions` | admin | [API-38](#get-adminpending-transactions) |
| `GET` | `/admin/bug-reports` | admin | [API-39](#get-adminbug-reports) |
| `POST` | `/admin/bug-reports/{report_id}/status` | admin | [API-40](#post-adminbug-reportsreport_idstatus) |
-| `GET` | `/admin/electrum` | admin | [API-47](#get-adminelectrum) — *da implementare (B-92)* |
+| `GET` | `/admin/electrum` | admin | [API-47](#get-adminelectrum) |
| `GET` | `/health` | nessuna | [API-41](#get-health) |
-| `GET` | `/health/ready` | nessuna | [API-46](#get-healthready) — *da implementare (B-92)* |
+| `GET` | `/health/ready` | nessuna | [API-46](#get-healthready) |
| `GET` | `/`, `/admin`, `/guida`, `/report-bug` | nessuna | [API-42](#pagine-e-file-statici) |
| `GET` | qualunque altro percorso (file statici) | nessuna | [API-43](#pagine-e-file-statici) |
@@ -314,7 +314,6 @@ esiste una API separata per l'amministrazione, solo un'autenticazione diversa.
[registrazione-e-depositi.md](registrazione-e-depositi.md).
*Codice:* `app/auth/routes.py:register`, `app/auth/routes.py:RegisterRequest` ·
*Test:* `tests/unit/test_auth.py`, `tests/unit/test_users.py`
- *Stato:* da implementare (B-92) · *Oggi:* non scrive audit.
#### POST /auth/login
@@ -886,10 +885,7 @@ dei parametri operativi in
- **Risposta `200`:** lista di `{"id", "event_type", "payload" (oggetto),
"user_id", "round_id", "created_at"}`, per `id` decrescente.
*Codice:* `app/api/routes/admin.py:list_audit_log` ·
- *Test:* `tests/unit/test_admin.py`
- *Stato:* da implementare (B-92), per filtri e paginazione · *Oggi:* accetta
- solo `limit`, quindi oltre le ultime 500 voci il registro non è
- consultabile dall'API.
+ *Test:* `tests/unit/test_admin.py`, `tests/unit/test_observability.py`
#### GET /admin/pending-transactions
@@ -918,9 +914,7 @@ dei parametri operativi in
`last_rotation` (motivo e ora dell'ultimo cambio di server, ad esempio punta
ferma, [connessione-electrum.md](connessione-electrum.md) ELC-21).
*Codice:* `app/api/routes/admin.py`, `app/electrum/listener.py:ElectrumListener.current_endpoint` ·
- *Test:* —
- *Stato:* da implementare (B-92) · *Oggi:* l'endpoint non esiste;
- `/admin` mostra solo l'altezza della catena letta da `GET /rounds/current`.
+ *Test:* `tests/unit/test_observability.py`
#### GET /admin/bug-reports
@@ -975,9 +969,7 @@ dei parametri operativi in
`scheduler_heartbeat_age_seconds` (intero o `null`). Nessuna autenticazione,
nessun dato sensibile: non espone né il server attivo né l'altezza. È il
`healthcheck` del container.
- *Codice:* `app/main.py:health` · *Test:* —
- *Stato:* da implementare (B-92) · *Oggi:* l'endpoint non esiste; esiste solo
- `GET /health` ([API-41]).
+ *Codice:* `app/main.py:health` · *Test:* `tests/unit/test_observability.py`
#### Pagine e file statici
diff --git a/docs/architettura.md b/docs/architettura.md
index 7a80b11..47213ba 100644
--- a/docs/architettura.md
+++ b/docs/architettura.md
@@ -19,7 +19,7 @@ flowchart LR
C -- "HTTP app:8123 (rete interna compose)" --> A["Container app 1 processo uvicorn FastAPI + 6 task asyncio"]
A -- "aiosqlite" --> D[("./data/db/plm_lottery.db SQLite, WAL")]
A -- "legge" --> K["./data/keys/master.xprv.enc"]
- A -- "scrive" --> L["./data/logs/app.log (e stdout, B-92)"]
+ A -- "scrive" --> L["./data/logs/app.log (e stdout)"]
A -- "Electrum (TCP/SSL) una connessione attiva" --> E["Server Electrum PLM primario + riserve"]
```
@@ -44,9 +44,8 @@ flowchart LR
stato in memoria descritto in
[Assunzioni di processo singolo](#assunzioni-di-processo-singolo) dipende da
questo.
- *Codice:* `Dockerfile` (`CMD`) · `docker-compose.yml`
- *Stato:* da implementare (B-92) · *Oggi:* uvicorn non legge gli header del
- proxy e il log di accesso registra l'IP di Caddy.
+ *Codice:* `Dockerfile` (`CMD`) · `docker-compose.yml` ·
+ *Test:* `tests/unit/test_observability.py`
- **[ARC-03]** All'avvio il container `app`, nell'ordine: rifiuta di partire
se il file indicato da `MASTER_KEY_PATH` non esiste (stampando il comando
per generarlo), poi esegue `alembic upgrade head`, poi avvia uvicorn con
@@ -94,9 +93,7 @@ flowchart LR
([operativita.md](operativita.md#segnali-da-tenere-docchio)), e il riavvio
resta affidato a `restart: unless-stopped` quando il processo esce.
*Codice:* `docker-compose.yml` · `app/main.py:health` ·
- *Test:* —
- *Stato:* da implementare (B-92) · *Oggi:* nessun `healthcheck`; esiste solo
- `/health`, che risponde sempre `ok` ([ARC-17]).
+ *Test:* `tests/unit/test_observability.py`
### Database
@@ -138,10 +135,8 @@ flowchart LR
`docker compose logs` li mostra senza entrare nel volume. La configurazione
avviene all'import di `app/main.py`, prima di qualunque altro modulo
applicativo.
- *Codice:* `app/logging_config.py:setup_logging`
- *Stato:* da implementare (B-92) · *Oggi:* i log dell'applicazione vanno solo
- nel file; su console arrivano solo quelli di uvicorn, per la sua
- configurazione predefinita.
+ *Codice:* `app/logging_config.py:setup_logging` ·
+ *Test:* `tests/unit/test_observability.py`
## Mappa del codice
@@ -771,13 +766,10 @@ flowchart LR
bloccato o caduto si aggiornano **solo** riaprendo la sezione. La sezione
Parametri non viene ricaricata dagli eventi. Il piano aggiunge al pannello
lo stato della connessione Electrum (endpoint, età dell'ultimo header) e i
- filtri dell'audit log (B-86, B-92).
+ filtri dell'audit log (B-92).
*Codice:* `app/static/admin.js:startChainStatusPolling` ·
`app/static/admin.js:connectAdminEvents` ·
`app/static/admin.js:onAdminServerEvent`
- *Stato:* da implementare (B-92), solo per lo stato Electrum e i
- filtri dell'audit · *Oggi:* il pannello mostra solo l'altezza della catena,
- e l'audit log le ultime righe senza filtri.
### `/report-bug` e `/guida`
diff --git a/docs/audit-log.md b/docs/audit-log.md
index 4a2cccc..7b55324 100644
--- a/docs/audit-log.md
+++ b/docs/audit-log.md
@@ -81,9 +81,6 @@ ogni tipo di evento. Le colonne della tabella `audit_log` sono descritte in
[api.md](api.md#get-adminaudit-log) (API-37), che ne è il proprietario.
*Codice:* `app/api/routes/admin.py:list_audit_log` ·
*Test:* `tests/unit/test_admin.py`
- *Stato:* da implementare (B-92), per filtri e paginazione · *Oggi:* accetta
- solo `limit`; le righe più vecchie delle ultime 500 si leggono solo
- direttamente dal database.
- **[AUD-08]** La sezione "Audit log" di `/admin` chiama l'endpoint senza
parametri (quindi le ultime 200 righe) al login e ogni volta che si apre la
@@ -256,9 +253,7 @@ flowchart LR
`user_id` sì (il nuovo utente), `round_id` no. Stessa transazione che crea
l'utente. È ciò che, dopo un ripristino, permette di ricostruire quali
indici erano già assegnati. Nessuna logica rilegge la riga.
- *Codice:* `app/auth/routes.py:register` · *Test:* —
- *Stato:* da implementare (B-92) · *Oggi:* la registrazione non scrive alcun
- evento.
+ *Codice:* `app/auth/routes.py:register` · *Test:* `tests/unit/test_observability.py`
- **[AUD-42]** `password_changed` — un utente ha cambiato la propria password
con `POST /users/me/change-password`
@@ -615,7 +610,6 @@ 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), per `user_registered`
## Log applicativo
@@ -638,11 +632,8 @@ va letto sul server.
container, quindi `docker compose logs app` li mostra insieme all'output di
avvio del container (controllo della chiave, migrazioni Alembic). La regola
è di [ARC-09](architettura.md#log), che ne è il proprietario.
- *Codice:* `app/logging_config.py:setup_logging`
- *Stato:* da implementare (B-92) · *Oggi:* il logger radice ha solo
- l'handler su file: `docker compose logs app` mostra solo l'output di avvio
- del container e ciò che uvicorn scrive con la propria configurazione
- (avvio, richieste).
+ *Codice:* `app/logging_config.py:setup_logging` ·
+ *Test:* `tests/unit/test_observability.py`
- **[AUD-35]** Un'eccezione non gestita in una richiesta HTTP viene registrata
per intero nel log (`Unhandled error on `) e al client
diff --git a/docs/connessione-electrum.md b/docs/connessione-electrum.md
index bf55c88..c2d9bd1 100644
--- a/docs/connessione-electrum.md
+++ b/docs/connessione-electrum.md
@@ -348,10 +348,7 @@ B-83).
una soglia di 360 s scatterebbe a vuoto ogni ventina di blocchi,
e^-3 ≈ 5%; la rotazione di [ELC-21] resta a 360 s perché agisce solo se un
altro server ha davvero una punta più alta),
- [api.md](api.md) B-92) e l'indicatore Electrum di `/admin`. L'altezza
+ [api.md](api.md) API-46) e l'indicatore Electrum di `/admin`. L'altezza
corrente resta visibile anche nella barra di stato della catena di `/` (da
`chain_tip_height` di `GET /rounds/current`).
- *Codice:* `app/electrum/listener.py:ElectrumListener.current_endpoint`, `app/main.py:health` · *Test:* —
- *Stato:* da implementare (B-92) · *Oggi:* `/health` risponde sempre
- `ok`; l'età dell'ultimo header non è registrata da nessuna parte e `/admin`
- mostra solo l'altezza.
+ *Codice:* `app/electrum/listener.py:ElectrumListener.current_endpoint`, `app/main.py:health` · *Test:* `tests/unit/test_observability.py`
diff --git a/docs/limiti-noti.md b/docs/limiti-noti.md
index 5e50f89..cb3ce5b 100644
--- a/docs/limiti-noti.md
+++ b/docs/limiti-noti.md
@@ -165,9 +165,9 @@ implementato il [piano delle correzioni](piano-correzioni.md).
(le scrive solo il reset da parte dell'admin).
- *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); chiusa da B-94 la parte `password_changed`. Login
- riusciti e falliti restano senza voce di audit dopo il piano.
+ - *Stato:* chiuso in parte da B-92 (`user_registered`, con indice di
+ derivazione e indirizzo) e da B-94 (`password_changed`). Login riusciti e
+ falliti restano senza voce di audit.
- *Per chiuderlo:* eventi di audit per questi casi (vedi
[audit-log.md](audit-log.md)).
diff --git a/docs/operativita.md b/docs/operativita.md
index bdf3c0a..d75a65b 100644
--- a/docs/operativita.md
+++ b/docs/operativita.md
@@ -83,7 +83,7 @@ dovranno allora rispettare quel proprietario.
[Segnali da tenere d'occhio](#segnali-da-tenere-docchio)).
*Codice:* `app/main.py:health`
-Dopo B-92 esiste anche `GET /health/ready`, che risponde 503 se la
+Esiste anche `GET /health/ready`, che risponde 503 se la
connessione Electrum manca, l'ultimo header è vecchio o lo scheduler non gira
([api.md](api.md) API-46), ed è il `healthcheck` del container
([architettura.md](architettura.md) ARC-61). Un container `unhealthy` **non**
@@ -389,7 +389,7 @@ container (controllo della chiave, migrazioni) e ciò che uvicorn scrive per
conto suo (avvio, richieste, errori di avvio). Vedi
[audit-log.md](audit-log.md#log-applicativo).
-Dopo B-92 il log applicativo va anche sull'output del container e si legge
+Il log applicativo va anche sull'output del container e si legge
con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
### Electrum irraggiungibile
@@ -849,17 +849,17 @@ con `docker compose logs app` ([architettura.md](architettura.md) ARC-09).
| Log | `data/logs/app.log` | `all N Electrum server(s) unreachable`, `round scheduler tick failed`, `could not bump`, `invariant violated` |
| Disco | `./data/db`, `./data/logs` | Il database cresce senza limite (registro di audit incluso); i log ruotano a 6 × 10 MB |
| Container | `docker compose ps` | `app` che si riavvia in continuazione: rifiuto all'avvio ([OPS-02]) |
- | Prontezza (dopo B-92) | `GET /health/ready`, `docker compose ps` | 503 / `unhealthy` ([OPS-04]) |
+ | Prontezza | `GET /health/ready`, `docker compose ps` | 503 / `unhealthy` ([OPS-04]) |
| Catena e sistema (dopo B-78, B-82, B-85, B-87, B-90) | Audit: `chain_reorg_detected`, `pending_tx_resurrected`, `payout_blocked`, `participant_reorged_out`, `rbf_bump_failed`, `restore_gap_detected`; `paused_reason` diverso da `operator` | Qualunque occorrenza ([OPS-30], [OPS-28]) |
*Codice:* `app/api/routes/rounds.py`, `app/api/routes/admin.py`,
`app/logging_config.py:setup_logging`
-Dopo B-92 l'indicatore Electrum di `/admin` mostra lo stato della
+L'indicatore Electrum di `/admin` mostra lo stato della
connessione ([ELC-29](connessione-electrum.md#stato-della-connessione)):
-connessa o no, server attivo, altezza, età dell'ultimo header. Oggi mostra
-solo l'ultima altezza nota ("Blocco #N"), che resta visibile anche a
-connessione caduta.
+connessa o no, server attivo, età dell'ultimo header e, al passaggio del
+mouse, l'ultimo cambio di server con il motivo; la barra mostra anche l'ultima
+altezza nota ("Blocco #N"), che resta visibile a connessione caduta.
## Prova su rete reale
diff --git a/docs/registrazione-e-depositi.md b/docs/registrazione-e-depositi.md
index 7da9378..ed09308 100644
--- a/docs/registrazione-e-depositi.md
+++ b/docs/registrazione-e-depositi.md
@@ -101,9 +101,7 @@ L'utente sceglie username e password (`POST /auth/register`). Il server:
- **[REG-16]** Ogni registrazione riuscita scrive l'evento `user_registered`
con l'indice di derivazione e l'indirizzo assegnati, nella stessa
transazione che crea l'utente.
- *Codice:* `app/auth/routes.py:register` · *Test:* —
- *Stato:* da implementare (B-92) · *Oggi:* la registrazione non scrive alcun
- evento di audit.
+ *Codice:* `app/auth/routes.py:register` · *Test:* `tests/unit/test_observability.py`
### Sessione e password
diff --git a/docs/sicurezza.md b/docs/sicurezza.md
index 61d701b..255989e 100644
--- a/docs/sicurezza.md
+++ b/docs/sicurezza.md
@@ -789,10 +789,8 @@ La struttura completa delle tabelle è in [modello-dati.md](modello-dati.md).
`decrypt_master_key.py`, per scelta e dopo conferma. WIF e password
generate dal reset compaiono solo nella risposta HTTP all'operatore.
*Codice:* `app/logging_config.py:setup_logging`, `app/api/routes/admin.py:user_privkey`,
- `app/api/routes/admin.py:reset_user_password`
- *Stato:* da implementare (B-92), per stdout e IP del client · *Oggi:* il
- log va solo nel file, e il log di accesso registra l'indirizzo di Caddy,
- non quello del client ([ARC-02](architettura.md#panoramica-del-deployment)).
+ `app/api/routes/admin.py:reset_user_password` ·
+ *Test:* `tests/unit/test_observability.py`
## Rischi accettati
diff --git a/docs/storico-bug.md b/docs/storico-bug.md
index 6156cb9..f7b433c 100644
--- a/docs/storico-bug.md
+++ b/docs/storico-bug.md
@@ -240,7 +240,7 @@ cifrava di nuovo il file sbagliato (vedi [operativita.md](operativita.md)).
| B-89 | Problemi della chiave rilevati solo al primo uso; gli script accettano qualunque xprv e scrivono file leggibili da tutti | Alta | *da registrare* | `test_master_key_check.py` | SEC, OPS |
| B-90 | Il ripristino di un backup riapre round già pagati e riassegna indici di derivazione | Alta | *da registrare* | `test_restore_gap.py` | OPS, REG |
| 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-92 | Nessuna verifica di salute o prontezza, stato Electrum invisibile, audit non filtrabile, log incompleti | Media | *da registrare* | `test_observability.py` | 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 | *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 |
@@ -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-94, B-95, B-96, B-98, B-99, B-100.
-- **Aperti:** dalla revisione del 2026-09-28, B-92 e B-93 (0 critici,
- 0 alti, 2 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-92, B-94, B-95, B-96, B-98, B-99, B-100.
+- **Aperti:** dalla revisione del 2026-09-28, B-93 (0 critici,
+ 0 alti, 1 medio), da implementare nell'ordine di
[piano-correzioni.md](piano-correzioni.md).
- **Prossimo numero libero:** `B-101`.
diff --git a/tests/unit/test_observability.py b/tests/unit/test_observability.py
new file mode 100644
index 0000000..f750643
--- /dev/null
+++ b/tests/unit/test_observability.py
@@ -0,0 +1,195 @@
+"""B-92 (API-37, API-46, API-47, AUD-41, ARC-02, ARC-09, ARC-61, ELC-29): readiness, Electrum
+status, audit browsing, the registration row and the logs."""
+
+import json
+import logging
+import sys
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+from sqlalchemy import select
+
+from tests.unit.test_abuse_limits import ADMIN, _register, client # noqa: F401 (fixture + helper)
+
+
+@pytest.fixture
+def listener(client):
+ from app.main import app
+
+ return app.state.electrum_listener
+
+
+# --- /health/ready -----------------------------------------------------------------
+
+
+def _ready_app(listener, *, header_age, heartbeat_age, connected=True):
+ from types import SimpleNamespace
+
+ from app.main import app
+
+ listener.client = object() if connected else None
+ listener.last_header_age_seconds = lambda: header_age
+ app.state.scheduler = SimpleNamespace(heartbeat_age_seconds=lambda: heartbeat_age)
+
+
+async def test_ready_only_when_connected_fresh_and_alive(client, listener):
+ _ready_app(listener, header_age=100, heartbeat_age=5)
+ ok = await client.get("/health/ready")
+ assert ok.status_code == 200 and ok.json() == {
+ "status": "ready",
+ "checks": {"electrum_connected": True, "last_header_age_seconds": 100, "scheduler_heartbeat_age_seconds": 5},
+ }
+ for kwargs in (
+ {"header_age": 100, "heartbeat_age": 5, "connected": False},
+ {"header_age": 1800, "heartbeat_age": 5},
+ {"header_age": None, "heartbeat_age": 5},
+ {"header_age": 100, "heartbeat_age": 30},
+ {"header_age": 100, "heartbeat_age": None},
+ ):
+ _ready_app(listener, **kwargs)
+ bad = await client.get("/health/ready")
+ assert bad.status_code == 503 and bad.json()["status"] == "not_ready", kwargs
+
+
+async def test_a_360_second_gap_between_blocks_is_not_unreadiness(client, listener):
+ """With 120 s blocks, 360 s of silence happens ~5% of the time by chance."""
+ _ready_app(listener, header_age=400, heartbeat_age=5)
+ assert (await client.get("/health/ready")).status_code == 200
+
+
+def test_the_scheduler_beats_on_every_tick(monkeypatch):
+ import asyncio
+
+ from app.rounds.scheduler import RoundScheduler
+
+ scheduler = RoundScheduler(None, None)
+ assert scheduler.heartbeat_age_seconds() is None
+
+ async def one_tick():
+ async def fake_tick():
+ raise asyncio.CancelledError
+
+ scheduler._tick = fake_tick
+ with pytest.raises(asyncio.CancelledError):
+ await scheduler.run()
+
+ asyncio.run(one_tick())
+ assert scheduler.heartbeat_age_seconds() == 0
+
+
+# --- /admin/electrum ---------------------------------------------------------------
+
+
+async def test_the_electrum_status_endpoint(client, listener):
+ from app.electrum.client import ElectrumEndpoint
+
+ listener._endpoints = [ElectrumEndpoint("a.example", 50002, True), ElectrumEndpoint("b.example", 50002, True)]
+ listener.client = object()
+ listener.tip_height = 538900
+ listener.last_rotation = {"reason": "stalled_tip", "from": "x:1", "at": "2026-10-18T00:00:00+00:00"}
+ body = (await client.get("/admin/electrum", headers=ADMIN)).json()
+ assert body["connected"] is True and body["endpoint"] == str(listener.current_endpoint)
+ assert body["tip_height"] == 538900 and body["fallback_count"] == 1 and body["quorum_enforced"] is True
+ assert body["last_rotation"]["reason"] == "stalled_tip"
+ listener.client = None
+ assert (await client.get("/admin/electrum", headers=ADMIN)).json()["endpoint"] is None
+ assert (await client.get("/admin/electrum")).status_code == 403
+
+
+async def test_the_listener_records_why_it_moved_to_another_server():
+ from app.electrum.listener import ServerStalledError, _rotation_reason
+ from app.rounds.draw import HeaderValidationError
+
+ assert _rotation_reason(ServerStalledError("x")) == "stalled_tip"
+ assert _rotation_reason(HeaderValidationError("x")) == "invalid_header"
+ assert _rotation_reason(RuntimeError("x")) == "error"
+
+
+# --- /admin/audit-log ---------------------------------------------------------------
+
+
+async def _seed_audit(rows):
+ from app.db import base as db_base
+ from app.db.models import AuditLog
+
+ async with db_base.AsyncSessionLocal() as session:
+ for event, user_id, round_id, at in rows:
+ session.add(AuditLog(event_type=event, payload_json="{}", user_id=user_id, round_id=round_id, created_at=at))
+ await session.commit()
+
+
+async def test_the_audit_log_is_filtered_and_paged(client):
+ now = datetime(2026, 10, 1, 12, tzinfo=timezone.utc)
+ await _seed_audit(
+ [(f"event_{i % 3}", i % 2, 10 + i % 2, now + timedelta(hours=i)) for i in range(12)]
+ )
+
+ async def ids(**params):
+ r = await client.get("/admin/audit-log", headers=ADMIN, params=params)
+ assert r.status_code == 200, r.text
+ return [e["id"] for e in r.json()]
+
+ everything = await ids(limit=500)
+ assert everything == sorted(everything, reverse=True) and len(everything) == 12
+
+ first = await ids(limit=5)
+ assert first == everything[:5]
+ assert await ids(limit=5, before_id=first[-1]) == everything[5:10]
+
+ r = await client.get("/admin/audit-log", headers=ADMIN, params=[("event_type", "event_0"), ("event_type", "event_1")])
+ assert {e["event_type"] for e in r.json()} == {"event_0", "event_1"}
+ assert await ids(event_type="nope") == []
+ assert len(await ids(round_id=10, user_id=0)) == 6
+ window = await ids(**{"from": "2026-10-01T14:00:00+00:00", "to": "2026-10-01T17:00:00+00:00"})
+ assert len(window) == 3 # 14:00, 15:00, 16:00 — `to` is exclusive
+
+ for bad in ({"from": "2026-10-01T14:00:00"}, {"before_id": "x"}, {"to": "yesterday"}):
+ r = await client.get("/admin/audit-log", headers=ADMIN, params=bad)
+ assert r.status_code == 422 and r.json()["detail"]["code"] == "invalid_request"
+
+
+# --- user_registered -------------------------------------------------------------------
+
+
+async def test_a_registration_writes_user_registered_in_its_transaction(client):
+ from app.db import base as db_base
+ from app.db.models import AuditLog, User
+
+ await _register(client, username="carol")
+ async with db_base.AsyncSessionLocal() as session:
+ user = (await session.scalars(select(User))).one()
+ row = (await session.scalars(select(AuditLog).where(AuditLog.event_type == "user_registered"))).one()
+ assert row.user_id == user.id and row.round_id is None
+ assert json.loads(row.payload_json) == {"derivation_index": 0, "address": user.address}
+ assert "carol" not in row.payload_json
+
+
+# --- logs and proxy -----------------------------------------------------------------------
+
+
+def test_the_app_logs_also_go_to_stdout(monkeypatch, tmp_path):
+ from app import logging_config
+
+ root = logging.getLogger()
+ before = list(root.handlers)
+ monkeypatch.setattr(logging_config, "LOG_DIR", tmp_path)
+ monkeypatch.setattr(logging_config, "LOG_FILE", tmp_path / "app.log")
+ try:
+ logging_config.setup_logging()
+ added = [h for h in root.handlers if h not in before]
+ assert any(isinstance(h, logging.StreamHandler) and h.stream is sys.stdout for h in added)
+ finally:
+ for h in [h for h in root.handlers if h not in before]:
+ root.removeHandler(h)
+ h.close()
+
+
+def test_uvicorn_trusts_only_the_proxy_and_the_container_is_checked():
+ from pathlib import Path
+
+ root = Path(__file__).resolve().parents[2]
+ dockerfile, compose = (root / "Dockerfile").read_text(), (root / "docker-compose.yml").read_text()
+ assert "--proxy-headers" in dockerfile and "--forwarded-allow-ips" in dockerfile
+ assert "FORWARDED_ALLOW_IPS=172.28.0.10" in compose and "ipv4_address: 172.28.0.10" in compose
+ assert "subnet: 172.28.0.0/24" in compose and "/health/ready" in compose