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)