From 575f1a535482f19d97e27de9497382c02d8ae6a1 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 18 Oct 2026 01:15:22 +0200 Subject: [PATCH] Readiness, Electrum status, audit browsing and logs (B-92, API-37, API-46, API-47, AUD-34, AUD-41, ARC-02, ARC-09, ARC-61, ELC-29, SEC-48) - GET /health/ready (Electrum connected, header < 1800s, scheduler heartbeat < 30s) is the container healthcheck; the scheduler beats on every tick and draw-wait iteration - GET /admin/electrum and an Electrum indicator in /admin (server, header age, last rotation) - GET /admin/audit-log: before_id, event_type, round_id, user_id, from, to; the panel gets filters - registration writes user_registered in its transaction - the app's own logs also go to stdout; uvicorn trusts X-Forwarded-For from Caddy's fixed address only, so the access log shows the client's IP Co-Authored-By: Claude Sonnet 5.5 --- BUGS.md | 12 +- CLAUDE.md | 4 +- Dockerfile | 2 +- README.md | 2 +- app/api/routes/admin.py | 74 +++++++++++- app/auth/routes.py | 10 ++ app/electrum/listener.py | 34 +++++- app/logging_config.py | 8 ++ app/main.py | 30 +++++ app/rounds/scheduler.py | 9 ++ app/static/admin.html | 14 ++- app/static/admin.js | 50 +++++++- docker-compose.yml | 25 ++++ docs/api.md | 18 +-- docs/architettura.md | 22 ++-- docs/audit-log.md | 15 +-- docs/connessione-electrum.md | 7 +- docs/limiti-noti.md | 6 +- docs/operativita.md | 14 +-- docs/registrazione-e-depositi.md | 4 +- docs/sicurezza.md | 6 +- docs/storico-bug.md | 8 +- tests/unit/test_observability.py | 195 +++++++++++++++++++++++++++++++ 23 files changed, 478 insertions(+), 91 deletions(-) create mode 100644 tests/unit/test_observability.py 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() { ${e.round_id ?? '—'} ${fmtDate(e.created_at)} - `).join('') || 'Nessun evento registrato.'; + `).join(''); + if (more === true) tbody.insertAdjacentHTML('beforeend', rows); + else tbody.innerHTML = rows || 'Nessun evento registrato.'; } 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