Files
sales-trainer/backend/tests/test_password_change.py

309 lines
10 KiB
Python

"""Sprint 3.7 separate password-change contract tests."""
from __future__ import annotations
import threading
from contextlib import contextmanager
def _headers(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _setup_admin(user_store) -> None:
user_store.complete_setup(
"admin",
"admin@example.com",
"admin-ready-password",
accepted_terms=True,
accepted_terms_at="2026-08-15T00:00:00Z",
)
def test_password_change_verifies_current_password_and_replaces_old(
client, user_store, login
):
_setup_admin(user_store)
old_password = "admin-ready-password"
new_password = "admin-new-password"
bearer = login("admin", old_password)["token"]
response = client.post(
"/api/auth/password",
headers=_headers(bearer),
json={"current_password": old_password, "new_password": new_password},
)
assert response.status_code == 200, response.get_json()
assert login("admin", new_password)["user"]["username"] == "admin"
old_login = client.post(
"/api/auth/login",
json={"username": "admin", "password": old_password},
)
assert old_login.status_code == 401
def test_password_change_invalidates_existing_bearer_token(client, user_store, login):
_setup_admin(user_store)
old_bearer = login("admin", "admin-ready-password")["token"]
response = client.post(
"/api/auth/password",
headers=_headers(old_bearer),
json={
"current_password": "admin-ready-password",
"new_password": "admin-new-password",
},
)
assert response.status_code == 200, response.get_json()
stale = client.get("/api/auth/me", headers=_headers(old_bearer))
assert stale.status_code == 401
def test_concurrent_admin_reset_cannot_reuse_stale_auth_version(user_store, monkeypatch):
user_store.complete_setup(
"admin",
"admin@example.com",
"admin-ready-password",
accepted_terms=True,
accepted_terms_at="2026-08-15T00:00:00Z",
)
from app.auth import users as users_module
real_generate = users_module.generate_password_hash
self_hash_started = threading.Event()
release_self_hash = threading.Event()
admin_collection_entered = threading.Event()
admin_read = threading.Event()
release_admin_read = threading.Event()
original_get = user_store.users.get_or_none
original_collection_lock = user_store.users.collection_lock
def blocking_hash(password):
if password == "admin-self-password":
self_hash_started.set()
if not release_self_hash.wait(timeout=5):
raise RuntimeError("self-service hash test gate timed out")
return real_generate(password)
def tracked_get(key):
result = original_get(key)
if threading.current_thread().name == "admin-reset" and key == "admin":
admin_read.set()
if not release_admin_read.wait(timeout=5):
raise RuntimeError("admin read test gate timed out")
return result
@contextmanager
def tracked_collection_lock():
if threading.current_thread().name == "admin-reset":
admin_collection_entered.set()
with original_collection_lock():
yield
monkeypatch.setattr(users_module, "generate_password_hash", blocking_hash)
monkeypatch.setattr(user_store.users, "get_or_none", tracked_get)
monkeypatch.setattr(user_store.users, "collection_lock", tracked_collection_lock)
errors = []
def self_change():
try:
user_store.change_password(
"admin", "admin-ready-password", "admin-self-password"
)
except Exception as exc: # pragma: no cover - assertion below reports it
errors.append(exc)
def admin_reset():
try:
user_store.set_password("admin", "admin-reset-password")
except Exception as exc: # pragma: no cover - assertion below reports it
errors.append(exc)
self_thread = threading.Thread(name="self-change", target=self_change)
admin_thread = threading.Thread(name="admin-reset", target=admin_reset)
self_thread.start()
assert self_hash_started.wait(timeout=5)
admin_thread.start()
assert admin_collection_entered.wait(timeout=5)
# Old code reads the user before waiting on record_lock; new code waits first.
admin_read.wait(timeout=1)
release_self_hash.set()
assert admin_read.wait(timeout=5)
release_admin_read.set()
self_thread.join(timeout=5)
admin_thread.join(timeout=5)
assert not self_thread.is_alive()
assert not admin_thread.is_alive()
assert errors == []
assert user_store.users.get("admin")["auth_version"] == 3
def test_concurrent_setup_cannot_reuse_stale_auth_version(user_store, monkeypatch):
from app.auth import users as users_module
real_generate = users_module.generate_password_hash
change_hash_started = threading.Event()
release_change_hash = threading.Event()
setup_collection_entered = threading.Event()
setup_read = threading.Event()
release_setup_read = threading.Event()
original_get = user_store.users.get_or_none
original_collection_lock = user_store.users.collection_lock
def blocking_hash(password):
if password == "admin-change-password":
change_hash_started.set()
if not release_change_hash.wait(timeout=5):
raise RuntimeError("change hash test gate timed out")
return real_generate(password)
def tracked_get(key):
result = original_get(key)
if threading.current_thread().name == "setup-complete" and key == "admin":
setup_read.set()
if not release_setup_read.wait(timeout=5):
raise RuntimeError("setup read test gate timed out")
return result
@contextmanager
def tracked_collection_lock():
if threading.current_thread().name == "setup-complete":
setup_collection_entered.set()
with original_collection_lock():
yield
monkeypatch.setattr(users_module, "generate_password_hash", blocking_hash)
monkeypatch.setattr(user_store.users, "get_or_none", tracked_get)
monkeypatch.setattr(user_store.users, "collection_lock", tracked_collection_lock)
errors = []
def change_password():
try:
user_store.change_password(
"admin", "pytest-bootstrap-password", "admin-change-password"
)
except Exception as exc: # pragma: no cover - assertion below reports it
errors.append(exc)
def complete_setup():
try:
user_store.complete_setup(
"admin",
"admin-setup@example.com",
"admin-setup-password",
accepted_terms=True,
accepted_terms_at="2026-08-15T00:00:00Z",
)
except Exception as exc: # pragma: no cover - assertion below reports it
errors.append(exc)
change_thread = threading.Thread(name="password-change", target=change_password)
setup_thread = threading.Thread(name="setup-complete", target=complete_setup)
change_thread.start()
assert change_hash_started.wait(timeout=5)
setup_thread.start()
assert setup_collection_entered.wait(timeout=5)
setup_read.wait(timeout=1)
release_change_hash.set()
assert setup_read.wait(timeout=5)
release_setup_read.set()
change_thread.join(timeout=5)
setup_thread.join(timeout=5)
assert not change_thread.is_alive()
assert not setup_thread.is_alive()
assert errors == []
assert user_store.users.get("admin")["auth_version"] == 2
def test_password_change_rejects_wrong_current_password(client, user_store, login):
_setup_admin(user_store)
bearer = login("admin", "admin-ready-password")["token"]
response = client.post(
"/api/auth/password",
headers=_headers(bearer),
json={
"current_password": "wrong-current-password",
"new_password": "admin-new-password",
},
)
assert response.status_code == 401
def test_password_change_cannot_target_another_username(client, user_store, login):
_setup_admin(user_store)
bearer = login("admin", "admin-ready-password")["token"]
response = client.post(
"/api/auth/password",
headers=_headers(bearer),
json={
"username": "someone-else",
"current_password": "admin-ready-password",
"new_password": "admin-new-password",
},
)
assert response.status_code == 403
def test_password_change_does_not_require_setup_terms(client, user_store, login):
_setup_admin(user_store)
bearer = login("admin", "admin-ready-password")["token"]
response = client.post(
"/api/auth/password",
headers=_headers(bearer),
json={
"current_password": "admin-ready-password",
"new_password": "admin-new-password",
},
)
assert response.status_code == 200, response.get_json()
def test_password_change_is_rate_limited(client, user_store, login):
_setup_admin(user_store)
bearer = login("admin", "admin-ready-password")["token"]
body = {
"current_password": "wrong-current-password",
"new_password": "admin-new-password",
}
responses = [
client.post("/api/auth/password", headers=_headers(bearer), json=body)
for _ in range(6)
]
assert [response.status_code for response in responses[:5]] == [401] * 5
assert responses[5].status_code == 429
def test_password_change_rate_limit_survives_worker_memory_reset(
client, user_store, login
):
_setup_admin(user_store)
bearer = login("admin", "admin-ready-password")["token"]
body = {
"current_password": "wrong-current-password",
"new_password": "admin-new-password",
}
for _ in range(5):
response = client.post("/api/auth/password", headers=_headers(bearer), json=body)
assert response.status_code == 401
from app.services import rate_limit
rate_limit._mem.clear()
blocked = client.post("/api/auth/password", headers=_headers(bearer), json=body)
assert blocked.status_code == 429