309 lines
10 KiB
Python
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
|