"""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