Files
plm-lottery/tests/unit/test_admin.py
T
davide f27fe6243c Harden session handling, add password reset/change, and firm up round polling
Session hardening: / and /admin now respond with Cache-Control: no-store, and
both pages re-derive their auth state on pageshow (event.persisted) as a
safety net against bfcache showing a stale logged-in/out view across
back/forward navigation. The user page also syncs logout across tabs via the
storage event, since localStorage is shared but in-memory JS state isn't.

Password recovery: admin gets a "Reset" button per user (POST
/admin/users/{id}/reset-password) that generates and sets a new password,
shown once — passwords are Argon2-hashed and can never be recovered, only
replaced. Users get self-service password change (POST
/users/me/change-password, requires the current password) under a new
Profilo tab, alongside read-only account info (username, address, balance,
join date).

Round display robustness: the user dashboard now refreshes immediately on
tab visibility change (background tabs get their timers throttled hard),
shows an explicit "connessione persa" state after repeated failed polls
instead of silently freezing on stale data, and polls faster both right when
the countdown hits zero and through the gap where the round is past its
deadline but still waiting for in-flight bets to confirm before the server
actually closes it.
2026-07-22 12:00:09 +02:00

214 lines
7.3 KiB
Python

import pytest
from cryptography.fernet import Fernet
from httpx import ASGITransport, AsyncClient
from app.config import settings
@pytest.fixture
async def client(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
monkeypatch.setattr(settings, "admin_token", "test-admin-token")
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
import app.wallet.hd as hd
hd._account_key = None
hd.generate_master_key()
from sqlalchemy.ext.asyncio import create_async_engine
from app.db import base as db_base
db_base.engine = create_async_engine(settings.database_url)
from sqlalchemy.ext.asyncio import async_sessionmaker
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
# app.db.session did `from app.db.base import AsyncSessionLocal` at its own
# first import, which only copies the reference as it was at that moment —
# reassigning db_base.AsyncSessionLocal above doesn't reach it. get_session()
# looks up its module global at call time, so rebinding it here (every test)
# keeps it pointed at *this* test's engine instead of whichever ran first.
from app.db import session as db_session
db_session.AsyncSessionLocal = db_base.AsyncSessionLocal
async with db_base.engine.begin() as conn:
await conn.run_sync(db_base.Base.metadata.create_all)
from app.api.routes.admin import router as admin_router
from fastapi import FastAPI
app = FastAPI()
app.include_router(admin_router)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
await db_base.engine.dispose()
async def test_admin_requires_token(client):
resp = await client.get("/admin/config")
assert resp.status_code == 403
async def test_admin_rejects_wrong_token(client):
resp = await client.get("/admin/config", headers={"X-Admin-Token": "wrong"})
assert resp.status_code == 403
async def test_admin_reads_and_updates_config(client):
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get("/admin/config", headers=headers)
assert resp.status_code == 200
assert resp.json()["fee_address"] == ""
resp = await client.put(
"/admin/config", headers=headers, json={"fee_address": "plm1qfeeaddress", "bet_amount_sats": 500_000_000}
)
assert resp.status_code == 200
body = resp.json()
assert body["fee_address"] == "plm1qfeeaddress"
assert body["bet_amount_sats"] == 500_000_000
resp = await client.get("/admin/config", headers=headers)
assert resp.json()["fee_address"] == "plm1qfeeaddress"
async def test_admin_can_pause_and_resume_the_lottery(client):
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get("/admin/config", headers=headers)
assert resp.json()["paused"] is False
resp = await client.post("/admin/pause", headers=headers)
assert resp.status_code == 200
assert resp.json()["paused"] is True
resp = await client.get("/admin/config", headers=headers)
assert resp.json()["paused"] is True
resp = await client.post("/admin/resume", headers=headers)
assert resp.status_code == 200
assert resp.json()["paused"] is False
async def test_admin_pause_requires_token(client):
resp = await client.post("/admin/pause")
assert resp.status_code == 403
async def test_admin_lists_users(client):
from app.db import base as db_base
from app.db.models import User
from app.wallet.hd import derive_user_address
async with db_base.AsyncSessionLocal() as session:
session.add(
User(
username="alice",
password_hash="unused",
derivation_index=0,
address=derive_user_address(0),
cached_balance_sats=1_000_000_000,
)
)
await session.commit()
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get("/admin/users", headers=headers)
assert resp.status_code == 200
body = resp.json()
assert len(body) == 1
assert body[0]["username"] == "alice"
assert body[0]["balance_sats"] == 1_000_000_000
async def test_admin_exports_user_privkey(client):
from embit.ec import PrivateKey
from app.db import base as db_base
from app.db.models import User
from app.wallet.hd import derive_user_address, derive_user_key
from app.wallet.plm_network import PLM_MAINNET
async with db_base.AsyncSessionLocal() as session:
user = User(
username="bob",
password_hash="unused",
derivation_index=1,
address=derive_user_address(1),
)
session.add(user)
await session.commit()
await session.refresh(user)
user_id = user.id
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get(f"/admin/users/{user_id}/privkey", headers=headers)
assert resp.status_code == 200
body = resp.json()
expected_wif = PrivateKey(derive_user_key(1).secret, compressed=True, network=PLM_MAINNET).wif(
network=PLM_MAINNET
)
assert body["wif"] == expected_wif
assert body["address"] == derive_user_address(1)
async def test_admin_privkey_404_for_unknown_user(client):
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get("/admin/users/999/privkey", headers=headers)
assert resp.status_code == 404
async def test_admin_resets_user_password(client):
from app.auth.security import hash_password, verify_password
from app.db import base as db_base
from app.db.models import User
from app.wallet.hd import derive_user_address
old_hash = hash_password("original-password")
async with db_base.AsyncSessionLocal() as session:
user = User(
username="carol",
password_hash=old_hash,
derivation_index=2,
address=derive_user_address(2),
)
session.add(user)
await session.commit()
await session.refresh(user)
user_id = user.id
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.post(f"/admin/users/{user_id}/reset-password", headers=headers)
assert resp.status_code == 200
body = resp.json()
assert body["username"] == "carol"
new_password = body["new_password"]
assert new_password and new_password != "original-password"
async with db_base.AsyncSessionLocal() as session:
refreshed = await session.get(User, user_id)
assert refreshed.password_hash != old_hash
assert verify_password(new_password, refreshed.password_hash)
assert not verify_password("original-password", refreshed.password_hash)
async def test_admin_reset_password_requires_token(client):
resp = await client.post("/admin/users/1/reset-password")
assert resp.status_code == 403
async def test_admin_reset_password_404_for_unknown_user(client):
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.post("/admin/users/999/reset-password", headers=headers)
assert resp.status_code == 404