From f27fe6243c3731ef3b6280b3d7eca39a5852b905 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Wed, 22 Jul 2026 12:00:09 +0200 Subject: [PATCH] Harden session handling, add password reset/change, and firm up round polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/api/routes/admin.py | 31 +++++++ app/api/routes/users.py | 37 ++++++++- app/main.py | 10 ++- app/static/admin.html | 54 ++++++++++-- app/static/index.html | 174 ++++++++++++++++++++++++++++++++++++--- docs/guida-admin.md | 14 +++- docs/guida-utente.md | 21 ++++- tests/unit/test_admin.py | 45 ++++++++++ tests/unit/test_users.py | 110 +++++++++++++++++++++++++ 9 files changed, 471 insertions(+), 25 deletions(-) create mode 100644 tests/unit/test_users.py diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index d301ed1..153a1ac 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -1,4 +1,5 @@ import json +import secrets from fastapi import APIRouter, Depends, Header, HTTPException, status from pydantic import BaseModel @@ -6,6 +7,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.log import write_audit_log +from app.auth.security import hash_password from app.config import settings from app.db.models import AuditLog, PendingTransaction, Round, User from app.db.session import get_session @@ -147,6 +149,35 @@ async def user_privkey(user_id: int, session: AsyncSession = Depends(get_session return AdminPrivkeyResponse(address=user.address, wif=wif) +class AdminPasswordResetResponse(BaseModel): + username: str + new_password: str + + +@router.post( + "/users/{user_id}/reset-password", + response_model=AdminPasswordResetResponse, + dependencies=[Depends(require_admin)], +) +async def reset_user_password( + user_id: int, session: AsyncSession = Depends(get_session) +) -> AdminPasswordResetResponse: + """Admin-only password reset for a user who's locked out: passwords are + Argon2-hashed (one-way), so an existing password can never be recovered or + displayed — this generates and sets a brand new one instead, shown once so + the admin can relay it to the user. There is no user-facing self-service + reset; only an admin (via /admin, token-gated) can trigger this.""" + user = await session.get(User, user_id) + if user is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found") + + new_password = secrets.token_urlsafe(12) + user.password_hash = hash_password(new_password) + await write_audit_log(session, "admin_password_reset", {"user_id": user_id}, user_id=user_id) + await session.commit() + return AdminPasswordResetResponse(username=user.username, new_password=new_password) + + class AdminRoundResponse(BaseModel): id: int status: str diff --git a/app/api/routes/users.py b/app/api/routes/users.py index 745b843..173025c 100644 --- a/app/api/routes/users.py +++ b/app/api/routes/users.py @@ -1,21 +1,54 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession from app.auth.dependencies import get_current_user +from app.auth.security import hash_password, verify_password from app.db.models import User +from app.db.session import get_session router = APIRouter(prefix="/users", tags=["users"]) +_MIN_PASSWORD_LENGTH = 8 + class MeResponse(BaseModel): id: int username: str address: str balance_sats: int + created_at: str @router.get("/me", response_model=MeResponse) async def me(user: User = Depends(get_current_user)) -> MeResponse: return MeResponse( - id=user.id, username=user.username, address=user.address, balance_sats=user.cached_balance_sats + id=user.id, + username=user.username, + address=user.address, + balance_sats=user.cached_balance_sats, + created_at=user.created_at.isoformat(), ) + + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + + +@router.post("/me/change-password", status_code=status.HTTP_204_NO_CONTENT) +async def change_password( + body: ChangePasswordRequest, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> None: + """Self-service password change — requires the current password, unlike the + admin-only /admin/users/{id}/reset-password (which is for a user who's + actually locked out and can't provide it).""" + if not verify_password(body.current_password, user.password_hash): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "current password is incorrect") + if len(body.new_password) < _MIN_PASSWORD_LENGTH: + raise HTTPException(status.HTTP_400_BAD_REQUEST, f"new password must be at least {_MIN_PASSWORD_LENGTH} characters") + + user.password_hash = hash_password(body.new_password) + await session.commit() diff --git a/app/main.py b/app/main.py index e49a3f1..c6e8bf6 100644 --- a/app/main.py +++ b/app/main.py @@ -82,9 +82,17 @@ async def health() -> dict[str, str]: return {"status": "ok"} +_NO_STORE_HEADERS = {"Cache-Control": "no-store"} + + +@app.get("/", include_in_schema=False) +async def index_page() -> FileResponse: + return FileResponse("app/static/index.html", headers=_NO_STORE_HEADERS) + + @app.get("/admin", include_in_schema=False) async def admin_panel() -> FileResponse: - return FileResponse("app/static/admin.html") + return FileResponse("app/static/admin.html", headers=_NO_STORE_HEADERS) app.mount("/", StaticFiles(directory="app/static", html=True), name="static") diff --git a/app/static/admin.html b/app/static/admin.html index 458032c..e462973 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -271,17 +271,17 @@

Utenti

-

Elenco utenti registrati, con saldo interno e accesso alla chiave privata per interventi manuali (es. restituire fondi bloccati).

+

Elenco utenti registrati, con saldo interno, accesso alla chiave privata per interventi manuali (es. restituire fondi bloccati) e reset password per chi resta bloccato fuori dall'account.

- ⚠ La chiave privata dà accesso completo ai fondi dell'utente. Ogni volta che la visualizzi viene registrata nell'audit log del server. Non condividerla, non salvarla altrove. + ⚠ La chiave privata dà accesso completo ai fondi dell'utente: ogni visualizzazione viene registrata nell'audit log, non condividerla né salvarla altrove. La password esistente di un utente non è mai recuperabile (è salvata solo come hash Argon2) — "Reset" ne genera una nuova al posto della vecchia, anche questo audit-loggato.
- +
IDUsernameIndirizzoSaldo (PLM)RegistratoChiave
IDUsernameIndirizzoSaldo (PLM)RegistratoChiavePassword
@@ -568,8 +568,12 @@ async function loadUsers() { + + + + - `).join('') || 'Nessun utente registrato.'; + `).join('') || 'Nessun utente registrato.'; } catch (e) { toast('Errore nel caricamento utenti: ' + e.message, 'error'); } @@ -598,6 +602,26 @@ async function revealPrivkey(userId, button) { }); } +async function resetUserPassword(userId, button) { + if (!window.confirm( + "Verrà generata una nuova password casuale per questo utente, che non potrà più accedere con quella vecchia. " + + "L'azione viene registrata nell'audit log. Continuare?" + )) { + return; + } + const box = document.getElementById('newpass-' + userId); + await withLoading(button, '…', async () => { + try { + const data = await callAdmin('POST', '/admin/users/' + userId + '/reset-password'); + box.textContent = 'Nuova password per ' + data.username + ': ' + data.new_password; + box.classList.remove('hidden'); + toast('Password reimpostata.', 'success'); + } catch (e) { + toast('Errore: ' + e.message, 'error'); + } + }); +} + async function loadRounds() { try { const rounds = await callAdmin('GET', '/admin/rounds'); @@ -662,11 +686,29 @@ document.getElementById('admin-token').addEventListener('keydown', (e) => { if (e.key === 'Enter') adminLogin(); }); -if (adminToken) { +function initAuthState() { + adminToken = sessionStorage.getItem('plm_admin_token'); + if (!adminToken) { + document.getElementById('dashboard-section').classList.add('hidden'); + document.getElementById('login-section').classList.remove('hidden'); + return; + } callAdmin('GET', '/admin/config') .then(() => { showDashboard(); return loadDashboard(); }) - .catch(() => { sessionStorage.removeItem('plm_admin_token'); adminToken = null; }); + .catch(() => adminLogout()); } + +// Bfcache can restore a frozen snapshot of this page (DOM/JS state as it was +// before navigating away) without re-running any of this script — so a stale +// view could survive across back/forward navigation, e.g. showing a dashboard +// for a token that's since been rotated or explicitly logged out of. Cache- +// Control: no-store on this response should already prevent that, but +// re-validate here too as a safety net for browsers that ignore it. +window.addEventListener('pageshow', (event) => { + if (event.persisted) initAuthState(); +}); + +initAuthState(); diff --git a/app/static/index.html b/app/static/index.html index 12d71d9..479302b 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -266,6 +266,7 @@ animation: status-dot-pulse 1400ms ease-in-out infinite; } .status-dot.status-waiting { background: var(--color-muted-foreground); } + .status-dot.status-offline { background: var(--color-destructive); } @keyframes status-dot-pulse { 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary) 45%, transparent); } 50% { box-shadow: 0 0 0 5px transparent; } @@ -308,6 +309,10 @@ Prelievo +
@@ -454,6 +459,33 @@
+
+
+

Profilo

+

Le tue informazioni account

+ +
+ +
+ +
PLM
+ +
+
+ +
+

Impostazioni

+

Cambia la password del tuo account

+ + + + + + + +
+
+ @@ -505,7 +537,7 @@ function switchTab(name) { } function switchPanel(name) { - for (const key of ['deposit', 'bet', 'withdraw']) { + for (const key of ['deposit', 'bet', 'withdraw', 'profile']) { document.getElementById('nav-' + key).classList.toggle('active', key === name); document.getElementById('panel-' + key).classList.toggle('active', key === name); } @@ -547,14 +579,36 @@ function updateChainStatusBar(data) { document.getElementById('maintenance-banner').classList.toggle('hidden', !data.lottery_paused); } +// After a couple of consecutive failed polls (network blip, server restart, +// tab suspended too long...), say so explicitly instead of silently leaving +// whatever status happened to be on screen — a frozen "Round aperto" that's +// actually minutes stale is worse than an honest "connessione persa". +const STALE_AFTER_FAILURES = 2; +let consecutiveFetchFailures = 0; + +function showConnectionLost() { + document.getElementById('chain-status-dot').className = 'status-dot status-offline'; + document.getElementById('chain-status-label').textContent = 'Connessione al server persa — riprovo…'; +} + +function noteFetchOutcome(ok) { + if (ok) { + consecutiveFetchFailures = 0; + return; + } + consecutiveFetchFailures++; + if (consecutiveFetchFailures >= STALE_AFTER_FAILURES) showConnectionLost(); +} + let chainOnlyInterval = null; async function refreshChainStatusOnly() { try { const data = await call('GET', '/rounds/current'); updateChainStatusBar(data); + noteFetchOutcome(true); } catch (e) { - // leave the last-known status on screen rather than blanking it out + noteFetchOutcome(false); } } @@ -569,6 +623,19 @@ function stopChainOnlyPolling() { chainOnlyInterval = null; } +// Background tabs get their timers throttled hard by the browser (sometimes to +// once a minute or less) — waiting for the next lazy tick after the user comes +// back could show a stale round state for a while. Refresh immediately instead +// as soon as the tab becomes visible again. +document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'visible') return; + if (chainOnlyInterval !== null) { + refreshChainStatusOnly(); + } else if (token) { + refreshRound(); + } +}); + // Per round_id: when we first saw it enter a drawing status (client clock), and // whether the win/lose result has already been shown for it. Local-only state, // not persisted — a page reload just re-derives it from the next poll. @@ -577,13 +644,26 @@ const revealedRounds = new Set(); let activeResultRoundId = null; // round_id whose win/lose result is on screen, if any let lastJackpotValue = null; +let timerHitZero = false; + function updateRoundTimer() { const el = document.getElementById('round-timer'); - if (!roundCloseAt) { el.textContent = '--:--'; return; } - const totalSec = Math.max(0, Math.floor((roundCloseAt - new Date()) / 1000)); + if (!roundCloseAt) { el.textContent = '--:--'; timerHitZero = false; return; } + const rawSec = Math.floor((roundCloseAt - new Date()) / 1000); + const totalSec = Math.max(0, rawSec); const mm = String(Math.floor(totalSec / 60)).padStart(2, '0'); const ss = String(totalSec % 60).padStart(2, '0'); el.textContent = mm + ':' + ss; + + // The countdown alone can't know the round actually closed server-side — poll + // right away instead of waiting up to 15s for the next scheduled tick, so the + // card doesn't sit on "00:00 · aperto" longer than necessary. + if (rawSec <= 0 && !timerHitZero) { + timerHitZero = true; + refreshRound(); + } else if (rawSec > 0) { + timerHitZero = false; + } } function showDrawingState() { @@ -613,6 +693,7 @@ function showNormalState() { async function refreshRound() { try { const data = await call('GET', '/rounds/current'); + noteFetchOutcome(true); updateChainStatusBar(data); document.getElementById('round-title').textContent = data.round_id ? 'Round #' + data.round_id + ' — ' + (ROUND_STATUS_LABELS[data.status] || data.status) @@ -663,8 +744,13 @@ async function refreshRound() { // one — keep it on screen through the cooldown gap instead of flashing back to // "Nessun round attivo". - scheduleNextRoundPoll(isDrawing); + // The countdown reaching zero doesn't mean the server has actually closed the + // round yet (it still waits for in-flight bets to confirm) — poll faster + // through that gap too, not just once status flips to closing/drawing/paying_out. + const pastDeadline = data.status === 'open' && roundCloseAt !== null && roundCloseAt - new Date() <= 0; + scheduleNextRoundPoll(isDrawing || pastDeadline); } catch (e) { + noteFetchOutcome(false); scheduleNextRoundPoll(false); } } @@ -733,8 +819,7 @@ async function login() { }); } -function logout() { - localStorage.clear(); +function resetToLoggedOutUI() { token = username = address = null; myUserId = null; activeResultRoundId = null; @@ -749,6 +834,40 @@ function logout() { startChainOnlyPolling(); } +function logout() { + localStorage.clear(); + resetToLoggedOutUI(); +} + +// Fires in every OTHER tab of this origin when one tab clears/changes plm_token +// (e.g. via logout()) — keeps all open tabs in sync instead of leaving stale +// ones showing a dashboard for a session that no longer exists anywhere else. +window.addEventListener('storage', (event) => { + if (event.key === 'plm_token' && !event.newValue) { + resetToLoggedOutUI(); + } +}); + +// Bfcache restores a frozen snapshot of the DOM/JS state from before the user +// navigated away, without re-running this script — so a stale "logged in" (or +// stale "logged out") view could persist across back/forward navigation. Cache- +// Control: no-store on this response should already prevent that, but re-derive +// the UI from storage here too as a safety net for browsers that ignore it. +window.addEventListener('pageshow', (event) => { + if (event.persisted) initAuthState(); +}); + +function initAuthState() { + token = localStorage.getItem('plm_token'); + username = localStorage.getItem('plm_username'); + address = localStorage.getItem('plm_address'); + if (token) { + showDashboard(); + } else { + resetToLoggedOutUI(); + } +} + async function copyAddress() { try { await navigator.clipboard.writeText(address); @@ -767,6 +886,41 @@ async function refreshMe() { const data = await call('GET', '/users/me'); myUserId = data.id; document.getElementById('dash-balance').textContent = data.balance_sats / SATS_PER_PLM; + document.getElementById('profile-username').textContent = data.username; + document.getElementById('profile-address').textContent = data.address; + document.getElementById('profile-balance').textContent = data.balance_sats / SATS_PER_PLM; + document.getElementById('profile-created-at').textContent = new Date(data.created_at).toLocaleDateString('it-IT'); + } catch (e) { + toast(e.message, 'error'); + } + }); +} + +async function changePassword() { + const btn = document.getElementById('change-password-btn'); + const currentPassword = document.getElementById('settings-current-password').value; + const newPassword = document.getElementById('settings-new-password').value; + const newPasswordConfirm = document.getElementById('settings-new-password-confirm').value; + + if (newPassword !== newPasswordConfirm) { + toast('Le nuove password non coincidono.', 'error'); + return; + } + if (newPassword.length < 8) { + toast('La nuova password deve avere almeno 8 caratteri.', 'error'); + return; + } + + await withLoading(btn, 'Aggiornamento…', async () => { + try { + await call('POST', '/users/me/change-password', { + current_password: currentPassword, + new_password: newPassword, + }); + document.getElementById('settings-current-password').value = ''; + document.getElementById('settings-new-password').value = ''; + document.getElementById('settings-new-password-confirm').value = ''; + toast('Password aggiornata.', 'success'); } catch (e) { toast(e.message, 'error'); } @@ -803,11 +957,7 @@ async function withdraw() { refreshMe(); } -if (token) { - showDashboard(); -} else { - startChainOnlyPolling(); -} +initAuthState(); diff --git a/docs/guida-admin.md b/docs/guida-admin.md index e54f463..0e59604 100644 --- a/docs/guida-admin.md +++ b/docs/guida-admin.md @@ -20,7 +20,7 @@ sola schermata di login. ## Sezioni della dashboard - **Parametri** — configurazione operativa (vedi tabella sotto) -- **Utenti** — elenco utenti, saldo, accesso alla chiave privata +- **Utenti** — elenco utenti, saldo, accesso alla chiave privata, reset password - **Round** — storico round: stato, vincitore, importi, txid di payout - **Transazioni pendenti** — bet/payout/prelievi non ancora confermati, candidati al fee-bump RBF - **Audit log** — eventi registrati dal sistema (config cambiata, bet, payout, accessi a chiavi private, ecc.) @@ -126,7 +126,7 @@ curl -X PUT https:///admin/config \ -d '{"fee_address": "plm1q...", "bet_amount_sats": 1000000000, "round_duration_seconds": 600}' ``` -## Utenti e chiave privata +## Utenti, chiave privata e reset password La card "Utenti" elenca id, username, indirizzo e saldo di ogni utente registrato. Il bottone "Mostra" su ogni riga rivela la chiave privata (WIF) @@ -137,6 +137,16 @@ falla: il server è già custodial, la chiave master da cui derivano tutte le chiavi utente vive sul server — questo pannello espone solo qualcosa che l'operatore può già fare via script. +Il bottone "Reset" nella colonna "Password" genera una **nuova password +casuale** per l'utente e sovrascrive quella esistente — mostrata una sola +volta nel pannello, così puoi comunicarla a chi ti ha chiesto aiuto perché +l'ha dimenticata. Non è un "recupero": le password sono salvate solo come +hash Argon2 (`app/auth/security.py`), quindi quella vecchia **non è mai +recuperabile** né per l'admin né per il codice stesso — l'unica opzione è +sempre sostituirla con una nuova. Anche questa azione è audit-loggata +(`admin_password_reset`) e non esiste alcun flusso self-service equivalente +per l'utente: solo un admin col token può farlo. + ## Limiti noti - Il token è unico e condiviso: non c'è identità per singolo admin né audit diff --git a/docs/guida-utente.md b/docs/guida-utente.md index c92d6ab..7393e36 100644 --- a/docs/guida-utente.md +++ b/docs/guida-utente.md @@ -20,7 +20,8 @@ login ogni volta che riapri la pagina. Dopo l'accesso vedi, in ordine: -1. **Barra account** — il tuo username e il bottone "Esci" (logout) +1. **Barra di navigazione** (fissa in alto) — il tuo username e il bottone + "Esci" (logout) nella riga superiore, e i tab delle sezioni subito sotto 2. **Card del round corrente** — sempre visibile, indipendentemente dalla sezione che stai guardando: - numero del round e stato (*aperto*, *in chiusura*, *estrazione in @@ -30,7 +31,7 @@ Dopo l'accesso vedi, in ordine: - **giocatori**: quanti hanno già piazzato una bet in questo round - **jackpot**: il totale in PLM che verrà distribuito (70% al vincitore, 30% in fee) -3. **Menu di navigazione** con tre sezioni: +3. **Tab di navigazione** con quattro sezioni: ### Estrazione del vincitore @@ -87,6 +88,22 @@ Form con due campi: Il prelievo viene costruito e trasmesso sulla rete; la fee di rete viene scalata dall'importo richiesto (non si aggiunge separatamente). +### Profilo + +Due card: + +- **Profilo**: le tue informazioni account — username, indirizzo di + deposito, saldo interno e data di iscrizione. Sola lettura, nessuna + modifica possibile qui. +- **Impostazioni**: form per **cambiare la password**. Serve la password + attuale (per conferma) più la nuova password (minimo 8 caratteri, digitata + due volte). Non richiede un nuovo login: la sessione attiva resta valida + anche dopo il cambio. + +Se hai dimenticato la password e non riesci più ad accedere, questa sezione +non ti aiuta (serve la password attuale) — contatta l'operatore della +piattaforma, che può reimpostartene una nuova dal pannello admin. + ## Notifiche Ogni azione (registrazione, login, bet, prelievo, ecc.) mostra un breve diff --git a/tests/unit/test_admin.py b/tests/unit/test_admin.py index b7ed2f2..1bc243f 100644 --- a/tests/unit/test_admin.py +++ b/tests/unit/test_admin.py @@ -166,3 +166,48 @@ 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 diff --git a/tests/unit/test_users.py b/tests/unit/test_users.py new file mode 100644 index 0000000..68ce33e --- /dev/null +++ b/tests/unit/test_users.py @@ -0,0 +1,110 @@ +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, "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 async_sessionmaker, create_async_engine + + from app.db import base as db_base + + # Import app.db.models before create_all — declarative models only register + # themselves into Base.metadata when their module is first imported, and + # nothing else in this fixture happens to trigger that import beforehand. + import app.db.models # noqa: F401 + + db_base.engine = create_async_engine(settings.database_url) + db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False) + + 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 fastapi import FastAPI + + from app.api.routes.users import router as users_router + from app.auth.routes import router as auth_router + from app.electrum.listener import ElectrumListener + + app = FastAPI() + app.include_router(auth_router) + app.include_router(users_router) + app.state.electrum_listener = ElectrumListener(lambda: None, db_base.AsyncSessionLocal) + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + await db_base.engine.dispose() + + +async def _register(client, username="alice", password="original-password"): + resp = await client.post("/auth/register", json={"username": username, "password": password}) + assert resp.status_code == 201 + return resp.json()["access_token"] + + +async def test_change_password_requires_current_password(client): + token = await _register(client) + headers = {"Authorization": f"Bearer {token}"} + + resp = await client.post( + "/users/me/change-password", + headers=headers, + json={"current_password": "wrong-password", "new_password": "brand-new-password"}, + ) + assert resp.status_code == 401 + + +async def test_change_password_updates_login(client): + token = await _register(client) + headers = {"Authorization": f"Bearer {token}"} + + resp = await client.post( + "/users/me/change-password", + headers=headers, + json={"current_password": "original-password", "new_password": "brand-new-password"}, + ) + assert resp.status_code == 204 + + resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"}) + assert resp.status_code == 401 + + resp = await client.post("/auth/login", json={"username": "alice", "password": "brand-new-password"}) + assert resp.status_code == 200 + + +async def test_change_password_rejects_too_short(client): + token = await _register(client) + headers = {"Authorization": f"Bearer {token}"} + + resp = await client.post( + "/users/me/change-password", + headers=headers, + json={"current_password": "original-password", "new_password": "short"}, + ) + assert resp.status_code == 400 + + +async def test_change_password_requires_auth(client): + resp = await client.post( + "/users/me/change-password", + json={"current_password": "x", "new_password": "brand-new-password"}, + ) + assert resp.status_code in (401, 403)