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.
This commit is contained in:
2026-07-22 12:00:09 +02:00
parent 162a63d04a
commit f27fe6243c
9 changed files with 471 additions and 25 deletions
+31
View File
@@ -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
+35 -2
View File
@@ -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()
+9 -1
View File
@@ -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")
+48 -6
View File
@@ -271,17 +271,17 @@
<div class="view" id="view-utenti">
<h2 class="section-title">Utenti</h2>
<p class="hint">Elenco utenti registrati, con saldo interno e accesso alla chiave privata per interventi manuali (es. restituire fondi bloccati).</p>
<p class="hint">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.</p>
<div class="warning-banner">
⚠ 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.
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr><th>ID</th><th>Username</th><th>Indirizzo</th><th>Saldo (PLM)</th><th>Registrato</th><th>Chiave</th></tr>
<tr><th>ID</th><th>Username</th><th>Indirizzo</th><th>Saldo (PLM)</th><th>Registrato</th><th>Chiave</th><th>Password</th></tr>
</thead>
<tbody id="users-tbody"></tbody>
</table>
@@ -568,8 +568,12 @@ async function loadUsers() {
<button class="reveal" onclick="revealPrivkey(${u.id}, this)">Mostra</button>
<div class="privkey-box hidden" id="privkey-${u.id}"></div>
</td>
<td>
<button class="secondary" style="width:auto;margin-top:0;min-height:30px;padding:4px 10px;font-size:0.78rem" onclick="resetUserPassword(${u.id}, this)">Reset</button>
<div class="privkey-box hidden" id="newpass-${u.id}"></div>
</td>
</tr>
`).join('') || '<tr><td colspan="6" class="hint">Nessun utente registrato.</td></tr>';
`).join('') || '<tr><td colspan="7" class="hint">Nessun utente registrato.</td></tr>';
} 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();
</script>
</body>
+162 -12
View File
@@ -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 @@
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
Prelievo
</button>
<button class="navbar-tab" id="nav-profile" onclick="switchPanel('profile')">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Profilo
</button>
</div>
</nav>
@@ -454,6 +459,33 @@
</div>
</div>
<div class="dash-panel" id="panel-profile">
<div class="card">
<h2>Profilo</h2>
<p class="hint">Le tue informazioni account</p>
<label>Username</label>
<div class="address-box"><span id="profile-username"></span></div>
<label>Indirizzo di deposito</label>
<div class="address-box"><span class="mono" id="profile-address"></span></div>
<label>Saldo interno</label>
<div class="address-box"><span class="mono" id="profile-balance"></span> <span class="balance-unit">PLM</span></div>
<label>Utente dal</label>
<div class="address-box"><span id="profile-created-at"></span></div>
</div>
<div class="card">
<h2>Impostazioni</h2>
<p class="hint">Cambia la password del tuo account</p>
<label for="settings-current-password">Password attuale</label>
<input id="settings-current-password" type="password" autocomplete="current-password">
<label for="settings-new-password">Nuova password</label>
<input id="settings-new-password" type="password" autocomplete="new-password">
<label for="settings-new-password-confirm">Conferma nuova password</label>
<input id="settings-new-password-confirm" type="password" autocomplete="new-password">
<button onclick="changePassword()" id="change-password-btn">Aggiorna password</button>
</div>
</div>
</section>
</div>
@@ -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();
</script>
</body>
+12 -2
View File
@@ -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://<host>/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
+19 -2
View File
@@ -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
+45
View File
@@ -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
+110
View File
@@ -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)