Files
plm-lottery/tests/unit/test_observability.py
T
davideandClaude Sonnet 5.5 575f1a5354 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 <noreply@anthropic.com>
2026-10-18 01:15:22 +02:00

196 lines
7.7 KiB
Python

"""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