- Demo accounts: super_admin-only provisioning into isolated DEMO_ORG_ID tenant, 30-day UTC trial on first login, revocable, one-time credential delivery via optional SES/webhook (never persisted). Adds boto3 dependency. - Analytics/report/export/privacy: shared bounded scan budget across users/groups/ sessions, tenant-consistent session/user/group joins, scalar-only CSV export (no nested persisted-value stringification). - Ownership/tenant isolation: canonical owner-tenant predicate for list/read/chat; client sees is_owned only, never owner_user_id. - Lifecycle/races: status transition validation, analyzing is an in-progress gate (no duplicate reanalysis), structured-ready publication, stale-variant revalidation. - Auth/setup/consent/JWT/OAuth/config: fail-closed consent, bounded JWT lifetime, provider-subject atomic OAuth identity, repeated-secret rejection, strict Persona trait validation. - Chat/session/privacy: pre-seller opener redaction, corrupt-session recovery, role-aware completed-chat dashboard routing. - Frontend: Training→product→personas→practice flow, demo/role/demo guards, is_owned-based ownership display, 320×568 and 500×768 responsive E2E. - 8 independent exact-five-key review scopes passed; backend 509, frontend 26, production build 1775 modules, isolated E2E 15.
1001 lines
33 KiB
Python
1001 lines
33 KiB
Python
"""Regression tests for first-time setup authorization."""
|
|
from __future__ import annotations
|
|
|
|
import multiprocessing as mp
|
|
from pathlib import Path
|
|
import threading
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
import pytest
|
|
|
|
from app.auth.users import AuthError
|
|
|
|
|
|
def _complete_setup_in_process(
|
|
data_dir: str,
|
|
email: str,
|
|
barrier,
|
|
result_queue,
|
|
) -> None:
|
|
"""Attempt setup in a forked worker after synchronizing before the write."""
|
|
import app.auth.users as users_module
|
|
from app.auth.users import UserStore
|
|
|
|
real_hash = users_module.generate_password_hash
|
|
|
|
def synchronized_hash(password: str) -> str:
|
|
barrier.wait(timeout=5)
|
|
return real_hash(password)
|
|
|
|
users_module.generate_password_hash = synchronized_hash
|
|
try:
|
|
updated = UserStore(Path(data_dir)).complete_setup(
|
|
"setup-process-race",
|
|
email,
|
|
"new-password",
|
|
accepted_terms=True,
|
|
accepted_terms_at="2026-08-13T00:00:00Z",
|
|
)
|
|
except AuthError as exc:
|
|
result_queue.put(("error", str(exc)))
|
|
except BaseException as exc: # pragma: no cover - diagnostic for child failures
|
|
result_queue.put(("exception", type(exc).__name__))
|
|
else:
|
|
result_queue.put(("ok", updated["email"]))
|
|
|
|
|
|
def _complete_setup_holding_email_lock(
|
|
data_dir: str,
|
|
email: str,
|
|
email_checked,
|
|
release_email_check,
|
|
result_queue,
|
|
) -> None:
|
|
"""Hold the collection lock after setup's email lookup for a cross-process race test."""
|
|
from app.auth.users import AuthError, UserStore
|
|
|
|
original_by_email = UserStore.by_email
|
|
|
|
def paused_by_email(self, email: str):
|
|
found = original_by_email(self, email)
|
|
email_checked.set()
|
|
release_email_check.wait(timeout=10)
|
|
return found
|
|
|
|
UserStore.by_email = paused_by_email
|
|
try:
|
|
updated = UserStore(Path(data_dir)).complete_setup(
|
|
"setup-create-process-race",
|
|
email,
|
|
"new-password",
|
|
accepted_terms=True,
|
|
accepted_terms_at="2026-08-13T00:00:00Z",
|
|
)
|
|
except AuthError as exc:
|
|
result_queue.put(("error", str(exc)))
|
|
except BaseException as exc: # pragma: no cover - diagnostic for child failures
|
|
result_queue.put(("exception", type(exc).__name__))
|
|
else:
|
|
result_queue.put(("ok", updated["username"]))
|
|
|
|
|
|
def _create_user_in_process(
|
|
data_dir: str,
|
|
email: str,
|
|
started,
|
|
result_queue,
|
|
) -> None:
|
|
"""Attempt an invited-user create in a separate worker process."""
|
|
from app.auth.users import AuthError, UserStore
|
|
|
|
started.set()
|
|
try:
|
|
created = UserStore(Path(data_dir)).create_user(
|
|
org_id="org-default",
|
|
username="invited-during-setup-process",
|
|
password="invite-password",
|
|
name="Invited During Setup Process",
|
|
role="user",
|
|
email=email,
|
|
)
|
|
except AuthError as exc:
|
|
result_queue.put(("error", str(exc)))
|
|
except BaseException as exc: # pragma: no cover - diagnostic for child failures
|
|
result_queue.put(("exception", type(exc).__name__))
|
|
else:
|
|
result_queue.put(("ok", created["username"]))
|
|
|
|
|
|
def test_setup_cannot_change_another_account(client, user_store, login):
|
|
"""A setup caller must not select an arbitrary username as mutation target."""
|
|
user_store.create_user(
|
|
org_id="org-default",
|
|
username="victim",
|
|
password="victim-password",
|
|
name="Victim",
|
|
role="user",
|
|
email="victim@example.com",
|
|
must_setup=False,
|
|
)
|
|
caller = user_store.create_user(
|
|
org_id="org-default",
|
|
username="caller",
|
|
password="caller-password",
|
|
name="Caller",
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
|
|
before = user_store.get_user("victim")
|
|
token = login(caller["username"], "caller-password")["token"]
|
|
|
|
response = client.post(
|
|
"/api/auth/setup",
|
|
json={
|
|
"username": "victim",
|
|
"email": "attacker@example.com",
|
|
"password": "attacker-password",
|
|
"accepted_terms": True,
|
|
},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
|
|
assert response.status_code in (401, 403), response.get_json()
|
|
after = user_store.get_user("victim")
|
|
assert after["email"] == before["email"]
|
|
assert after["password_hash"] == before["password_hash"]
|
|
assert after["must_setup"] is False
|
|
|
|
|
|
def test_setup_updates_only_the_authenticated_user(client, user_store, login):
|
|
caller = user_store.create_user(
|
|
org_id="org-default",
|
|
username="setup-user",
|
|
password="old-password",
|
|
name="Setup User",
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
token = login(caller["username"], "old-password")["token"]
|
|
|
|
response = client.post(
|
|
"/api/auth/setup",
|
|
json={
|
|
"email": "setup@example.com",
|
|
"password": "new-password",
|
|
"accepted_terms": True,
|
|
},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
body = response.get_json()
|
|
assert body["must_setup"] is False
|
|
assert "password_hash" not in body["user"]
|
|
updated = user_store.get_user("setup-user")
|
|
assert updated["email"] == "setup@example.com"
|
|
assert updated["must_setup"] is False
|
|
|
|
|
|
def test_inconsistent_unaccepted_account_is_revoked(
|
|
client, user_store, login
|
|
):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="legacy-unaccepted-user",
|
|
password="legacy-unaccepted-password",
|
|
name="Legacy Unaccepted User",
|
|
role="user",
|
|
must_setup=False,
|
|
accepted_terms=False,
|
|
)
|
|
token = login(user["username"], "legacy-unaccepted-password")["token"]
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
|
|
assert client.get("/api/auth/me", headers=headers).status_code == 401
|
|
blocked = client.get("/api/groups", headers=headers)
|
|
assert blocked.status_code == 401, blocked.get_json()
|
|
|
|
|
|
def test_missing_setup_marker_is_rejected_at_login_without_state_leak(
|
|
client, user_store
|
|
):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="legacy-missing-setup-marker",
|
|
password="legacy-missing-setup-password",
|
|
name="Legacy Missing Setup Marker",
|
|
role="user",
|
|
must_setup=False,
|
|
accepted_terms=True,
|
|
)
|
|
persisted = dict(user)
|
|
persisted.pop("must_setup")
|
|
user_store.users.replace(user["username"], persisted)
|
|
response = client.post(
|
|
"/api/auth/login",
|
|
json={
|
|
"username": user["username"],
|
|
"password": "legacy-missing-setup-password",
|
|
},
|
|
)
|
|
assert response.status_code == 401, response.get_json()
|
|
assert response.get_json() == {"error": "invalid credentials"}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"terms_payload",
|
|
[{}, {"accepted_terms": False}, {"accepted_terms": "true"}],
|
|
ids=["omitted", "false", "non_boolean"],
|
|
)
|
|
def test_setup_requires_literal_true_terms_and_preserves_state(
|
|
client, user_store, login, terms_payload
|
|
):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username=f"terms-{terms_payload.get('accepted_terms', 'omitted')}",
|
|
password="terms-password",
|
|
name="Terms User",
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
token = login(user["username"], "terms-password")["token"]
|
|
before = user_store.get_user(user["username"])
|
|
payload = {
|
|
"email": "terms-boundary@example.com",
|
|
"password": "terms-new-password",
|
|
**terms_payload,
|
|
}
|
|
|
|
response = client.post(
|
|
"/api/auth/setup",
|
|
json=payload,
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
|
|
assert response.status_code == 400, response.get_json()
|
|
assert user_store.get_user(user["username"]) == before
|
|
|
|
|
|
def test_create_user_requires_literal_boolean_must_setup(user_store):
|
|
with pytest.raises(AuthError, match="must_setup must be boolean"):
|
|
user_store.create_user(
|
|
org_id="org-default",
|
|
username="malformed-must-setup-create",
|
|
password="malformed-password",
|
|
name="Malformed Must Setup Create",
|
|
role="user",
|
|
must_setup="false",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("accepted_terms", [False, "true", 1])
|
|
def test_complete_setup_requires_literal_true_terms(
|
|
user_store, accepted_terms
|
|
):
|
|
"""The service boundary must enforce literal consent, not just the HTTP route."""
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username=f"service-terms-{str(accepted_terms).lower()}",
|
|
password="terms-password",
|
|
name="Service Terms User",
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
before = user_store.get_user(user["username"])
|
|
|
|
with pytest.raises(AuthError, match="terms acceptance is required"):
|
|
user_store.complete_setup(
|
|
user["username"],
|
|
"service-terms@example.com",
|
|
"terms-new-password",
|
|
accepted_terms=accepted_terms,
|
|
accepted_terms_at="2026-08-13T00:00:00Z",
|
|
)
|
|
|
|
assert user_store.get_user(user["username"]) == before
|
|
|
|
|
|
def test_setup_failure_paths_do_not_partially_update_account(
|
|
user_store, monkeypatch
|
|
):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="setup-failure-paths",
|
|
password="old-password",
|
|
name="Setup Failure Paths",
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
before = user_store.get_user(user["username"])
|
|
shared_email = "already-owned@example.com"
|
|
user_store.create_user(
|
|
org_id="org-default",
|
|
username="email-owner",
|
|
password="owner-password",
|
|
name="Email Owner",
|
|
role="user",
|
|
email=shared_email,
|
|
)
|
|
|
|
with pytest.raises(AuthError, match="invalid email"):
|
|
user_store.complete_setup(
|
|
user["username"],
|
|
"invalid-email",
|
|
"new-password",
|
|
accepted_terms=True,
|
|
accepted_terms_at="2026-08-13T00:00:00Z",
|
|
)
|
|
assert user_store.get_user(user["username"]) == before
|
|
|
|
with pytest.raises(AuthError, match="email already exists"):
|
|
user_store.complete_setup(
|
|
user["username"],
|
|
shared_email,
|
|
"new-password",
|
|
accepted_terms=True,
|
|
accepted_terms_at="2026-08-13T00:00:00Z",
|
|
)
|
|
assert user_store.get_user(user["username"]) == before
|
|
|
|
def explode(_password: str) -> str:
|
|
raise RuntimeError("hashing failed")
|
|
|
|
monkeypatch.setattr("app.auth.users.generate_password_hash", explode)
|
|
with pytest.raises(RuntimeError, match="hashing failed"):
|
|
user_store.complete_setup(
|
|
user["username"],
|
|
"valid-after-hash-failure@example.com",
|
|
"new-password",
|
|
accepted_terms=True,
|
|
accepted_terms_at="2026-08-13T00:00:00Z",
|
|
)
|
|
assert user_store.get_user(user["username"]) == before
|
|
|
|
|
|
def test_password_policy_rejects_short_setup_and_password_change(
|
|
user_store,
|
|
):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="password-policy-user",
|
|
password="initial-password",
|
|
name="Password Policy User",
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
before = user_store.get_user(user["username"])
|
|
|
|
with pytest.raises(AuthError, match="password must be at least 12 characters"):
|
|
user_store.complete_setup(
|
|
user["username"],
|
|
"password-policy@example.com",
|
|
"12345678901",
|
|
accepted_terms=True,
|
|
accepted_terms_at="2026-08-13T00:00:00Z",
|
|
)
|
|
assert user_store.get_user(user["username"]) == before
|
|
|
|
with pytest.raises(AuthError, match="password must be at least 12 characters"):
|
|
user_store.set_password(user["username"], "12345678901")
|
|
assert user_store.get_user(user["username"]) == before
|
|
|
|
with pytest.raises(AuthError, match="password must be at least 12 characters"):
|
|
user_store.create_user(
|
|
org_id="org-default",
|
|
username="weak-created-user",
|
|
password="12345678901",
|
|
name="Weak Created User",
|
|
role="user",
|
|
)
|
|
assert user_store.get_user_or_none("weak-created-user") is None
|
|
|
|
|
|
def test_http_setup_rejects_weak_password_without_mutation(client, user_store, login):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="http-password-policy-user",
|
|
password="initial-password",
|
|
name="HTTP Password Policy User",
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
before = user_store.get_user(user["username"])
|
|
token = login(user["username"], "initial-password")["token"]
|
|
|
|
response = client.post(
|
|
"/api/auth/setup",
|
|
json={
|
|
"email": "http-password-policy@example.com",
|
|
"password": "12345678901",
|
|
"accepted_terms": True,
|
|
},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
|
|
assert response.status_code == 400, response.get_json()
|
|
assert user_store.get_user(user["username"]) == before
|
|
|
|
|
|
def test_http_setup_rejects_non_string_email_without_mutation(
|
|
client, user_store, login
|
|
):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="http-email-type-user",
|
|
password="initial-password",
|
|
name="HTTP Email Type User",
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
before = user_store.get_user(user["username"])
|
|
token = login(user["username"], "initial-password")["token"]
|
|
|
|
response = client.post(
|
|
"/api/auth/setup",
|
|
json={
|
|
"email": 123,
|
|
"password": "valid-setup-password",
|
|
"accepted_terms": True,
|
|
},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
|
|
assert response.status_code == 400, response.get_json()
|
|
assert user_store.get_user(user["username"]) == before
|
|
|
|
|
|
def test_profile_update_invalid_email_does_not_partially_change_name(
|
|
client, user_store, login
|
|
):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="profile-partial-user",
|
|
password="profile-original-password",
|
|
name="Original Profile Name",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
before = user_store.get_user(user["username"])
|
|
token = login(user["username"], "profile-original-password")["token"]
|
|
|
|
response = client.patch(
|
|
"/api/auth/profile",
|
|
json={"name": "Updated Profile Name", "email": "invalid-email"},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
|
|
assert response.status_code == 400, response.get_json()
|
|
assert user_store.get_user(user["username"]) == before
|
|
|
|
|
|
def test_set_email_and_complete_setup_share_email_reservation_lock(
|
|
user_store, monkeypatch
|
|
):
|
|
"""Profile email updates cannot race first-time setup for the same email."""
|
|
pending = user_store.create_user(
|
|
org_id="org-default",
|
|
username="setup-profile-race",
|
|
password="old-password",
|
|
name="Setup Profile Race",
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
profile = user_store.create_user(
|
|
org_id="org-default",
|
|
username="profile-email-race",
|
|
password="profile-password",
|
|
name="Profile Email Race",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.auth.users.generate_password_hash",
|
|
lambda password: f"test-hash:{password}",
|
|
)
|
|
|
|
original_by_email = user_store.by_email
|
|
first_lookup_paused = threading.Event()
|
|
release_first_lookup = threading.Event()
|
|
pause_once = threading.Lock()
|
|
already_paused = False
|
|
|
|
def paused_by_email(email: str):
|
|
nonlocal already_paused
|
|
found = original_by_email(email)
|
|
with pause_once:
|
|
should_pause = not already_paused
|
|
already_paused = True
|
|
if should_pause:
|
|
first_lookup_paused.set()
|
|
assert release_first_lookup.wait(timeout=5)
|
|
return found
|
|
|
|
monkeypatch.setattr(user_store, "by_email", paused_by_email)
|
|
shared_email = "profile-setup-race@example.com"
|
|
|
|
def profile_attempt() -> tuple[str, str]:
|
|
try:
|
|
updated = user_store.set_email(profile["username"], shared_email)
|
|
except AuthError as exc:
|
|
return ("error", str(exc))
|
|
return ("ok", updated["username"])
|
|
|
|
def setup_attempt() -> tuple[str, str]:
|
|
try:
|
|
updated = user_store.complete_setup(
|
|
pending["username"],
|
|
shared_email,
|
|
"new-password",
|
|
accepted_terms=True,
|
|
accepted_terms_at="2026-08-13T00:00:00Z",
|
|
)
|
|
except AuthError as exc:
|
|
return ("error", str(exc))
|
|
return ("ok", updated["username"])
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
profile_future = pool.submit(profile_attempt)
|
|
assert first_lookup_paused.wait(timeout=5)
|
|
setup_future = pool.submit(setup_attempt)
|
|
release_first_lookup.set()
|
|
results = [
|
|
profile_future.result(timeout=5),
|
|
setup_future.result(timeout=5),
|
|
]
|
|
|
|
assert [status for status, _value in results].count("ok") == 1
|
|
assert [status for status, _value in results].count("error") == 1
|
|
assert {value for status, value in results if status == "error"} == {
|
|
"a user with this email already exists"
|
|
}
|
|
matching = [
|
|
user for user in user_store.users.all() if user.get("email") == shared_email
|
|
]
|
|
assert len(matching) == 1
|
|
|
|
|
|
def test_setup_rejects_user_who_already_completed_setup(client, user_store, login):
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="already-ready",
|
|
password="old-password",
|
|
name="Already Ready",
|
|
role="user",
|
|
email="ready@example.com",
|
|
must_setup=False,
|
|
)
|
|
token = login(user["username"], "old-password")["token"]
|
|
|
|
response = client.post(
|
|
"/api/auth/setup",
|
|
json={
|
|
"email": "new@example.com",
|
|
"password": "new-password",
|
|
"accepted_terms": True,
|
|
},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
|
|
assert response.status_code == 409, response.get_json()
|
|
|
|
|
|
def test_complete_setup_is_atomic_under_concurrency(user_store, monkeypatch):
|
|
"""Only one concurrent first-time setup may transition the account."""
|
|
user_store.create_user(
|
|
org_id="org-default",
|
|
username="setup-race",
|
|
password="old-password",
|
|
name="Setup Race",
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
|
|
import app.auth.users as users_module
|
|
|
|
real_hash = users_module.generate_password_hash
|
|
entered_hash = threading.Barrier(2)
|
|
|
|
def synchronized_hash(password: str) -> str:
|
|
entered_hash.wait(timeout=2)
|
|
return real_hash(password)
|
|
|
|
monkeypatch.setattr(users_module, "generate_password_hash", synchronized_hash)
|
|
|
|
def attempt(index: int) -> tuple[str, str]:
|
|
try:
|
|
updated = user_store.complete_setup(
|
|
"setup-race",
|
|
f"race-{index}@example.com",
|
|
f"new-password-{index}",
|
|
accepted_terms=True,
|
|
accepted_terms_at="2026-08-13T00:00:00Z",
|
|
)
|
|
except AuthError as exc:
|
|
return ("error", str(exc))
|
|
return ("ok", updated["email"])
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
results = list(pool.map(attempt, (1, 2)))
|
|
|
|
assert [status for status, _value in results].count("ok") == 1
|
|
assert [status for status, _value in results].count("error") == 1
|
|
assert {value for status, value in results if status == "error"} == {
|
|
"setup already completed"
|
|
}
|
|
final = user_store.get_user("setup-race")
|
|
assert final["must_setup"] is False
|
|
assert final["email"] in {"race-1@example.com", "race-2@example.com"}
|
|
|
|
|
|
def test_complete_setup_reserves_email_across_concurrent_users(user_store, monkeypatch):
|
|
"""Concurrent setup cannot assign one email address to two accounts."""
|
|
for username in ("setup-email-race-1", "setup-email-race-2"):
|
|
user_store.create_user(
|
|
org_id="org-default",
|
|
username=username,
|
|
password="old-password",
|
|
name=username,
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
|
|
import app.auth.users as users_module
|
|
|
|
real_hash = users_module.generate_password_hash
|
|
entered_hash = threading.Barrier(2)
|
|
|
|
def synchronized_hash(password: str) -> str:
|
|
entered_hash.wait(timeout=2)
|
|
return real_hash(password)
|
|
|
|
monkeypatch.setattr(users_module, "generate_password_hash", synchronized_hash)
|
|
|
|
def attempt(username: str) -> tuple[str, str]:
|
|
try:
|
|
updated = user_store.complete_setup(
|
|
username,
|
|
"shared-setup@example.com",
|
|
"new-password",
|
|
accepted_terms=True,
|
|
accepted_terms_at="2026-08-13T00:00:00Z",
|
|
)
|
|
except AuthError as exc:
|
|
return ("error", str(exc))
|
|
return ("ok", updated["username"])
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
results = list(pool.map(attempt, ("setup-email-race-1", "setup-email-race-2")))
|
|
|
|
assert [status for status, _value in results].count("ok") == 1
|
|
assert [status for status, _value in results].count("error") == 1
|
|
assert {value for status, value in results if status == "error"} == {
|
|
"a user with this email already exists"
|
|
}
|
|
|
|
|
|
def test_create_user_and_setup_share_email_reservation_lock(user_store, monkeypatch):
|
|
"""Invite creation cannot race first-time setup for the same email."""
|
|
user_store.create_user(
|
|
org_id="org-default",
|
|
username="setup-create-race",
|
|
password="old-password",
|
|
name="Setup Create Race",
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
|
|
import app.auth.users as users_module
|
|
|
|
monkeypatch.setattr(
|
|
users_module,
|
|
"generate_password_hash",
|
|
lambda password: f"test-hash:{password}",
|
|
)
|
|
original_email_exists = user_store.email_exists
|
|
original_by_email = user_store.by_email
|
|
original_complete_setup = user_store.complete_setup
|
|
create_checked = threading.Event()
|
|
release_create = threading.Event()
|
|
setup_checked = threading.Event()
|
|
setup_called = threading.Event()
|
|
setup_context = threading.local()
|
|
|
|
def paused_email_exists(email: str) -> bool:
|
|
found = original_email_exists(email)
|
|
create_checked.set()
|
|
assert release_create.wait(timeout=5)
|
|
return found
|
|
|
|
def tracked_by_email(email: str):
|
|
found = original_by_email(email)
|
|
if getattr(setup_context, "active", False):
|
|
setup_checked.set()
|
|
return found
|
|
|
|
def tracked_complete_setup(*args, **kwargs):
|
|
setup_called.set()
|
|
setup_context.active = True
|
|
try:
|
|
return original_complete_setup(*args, **kwargs)
|
|
finally:
|
|
setup_context.active = False
|
|
|
|
monkeypatch.setattr(user_store, "email_exists", paused_email_exists)
|
|
monkeypatch.setattr(user_store, "by_email", tracked_by_email)
|
|
monkeypatch.setattr(user_store, "complete_setup", tracked_complete_setup)
|
|
|
|
shared_email = "create-setup-race@example.com"
|
|
|
|
def create_attempt() -> tuple[str, str]:
|
|
try:
|
|
created = user_store.create_user(
|
|
org_id="org-default",
|
|
username="invited-during-setup",
|
|
password="invite-password",
|
|
name="Invited During Setup",
|
|
role="user",
|
|
email=shared_email,
|
|
)
|
|
except AuthError as exc:
|
|
return ("error", str(exc))
|
|
return ("ok", created["username"])
|
|
|
|
def setup_attempt() -> tuple[str, str]:
|
|
try:
|
|
updated = user_store.complete_setup(
|
|
"setup-create-race",
|
|
shared_email,
|
|
"new-password",
|
|
accepted_terms=True,
|
|
accepted_terms_at="2026-08-13T00:00:00Z",
|
|
)
|
|
except AuthError as exc:
|
|
return ("error", str(exc))
|
|
return ("ok", updated["username"])
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
create_future = pool.submit(create_attempt)
|
|
assert create_checked.wait(timeout=5)
|
|
setup_future = pool.submit(setup_attempt)
|
|
assert setup_called.wait(timeout=5)
|
|
setup_reached_email_before_release = setup_checked.wait(timeout=1)
|
|
release_create.set()
|
|
results = [
|
|
create_future.result(timeout=5),
|
|
setup_future.result(timeout=5),
|
|
]
|
|
|
|
assert setup_reached_email_before_release is False
|
|
assert [status for status, _value in results].count("ok") == 1
|
|
assert [status for status, _value in results].count("error") == 1
|
|
assert {value for status, value in results if status == "error"} == {
|
|
"a user with this email already exists"
|
|
}
|
|
matching = [user for user in user_store.users.all() if user.get("email") == shared_email]
|
|
assert [user["username"] for user in matching] in [
|
|
["invited-during-setup"],
|
|
["setup-create-race"],
|
|
]
|
|
|
|
|
|
def test_create_user_and_setup_share_email_reservation_lock_across_processes(user_store):
|
|
"""The email reservation must hold when invite and setup run in separate workers."""
|
|
user_store.create_user(
|
|
org_id="org-default",
|
|
username="setup-create-process-race",
|
|
password="old-password",
|
|
name="Setup Create Process Race",
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
|
|
context = mp.get_context("fork")
|
|
email_checked = context.Event()
|
|
release_email_check = context.Event()
|
|
create_started = context.Event()
|
|
result_queue = context.Queue()
|
|
data_dir = str(user_store.users.root.parent)
|
|
shared_email = "create-setup-process-race@example.com"
|
|
setup_process = context.Process(
|
|
target=_complete_setup_holding_email_lock,
|
|
args=(data_dir, shared_email, email_checked, release_email_check, result_queue),
|
|
)
|
|
setup_process.start()
|
|
create_process = None
|
|
try:
|
|
assert email_checked.wait(timeout=10)
|
|
create_process = context.Process(
|
|
target=_create_user_in_process,
|
|
args=(data_dir, shared_email, create_started, result_queue),
|
|
)
|
|
create_process.start()
|
|
assert create_started.wait(timeout=5)
|
|
create_process.join(timeout=1)
|
|
assert create_process.is_alive()
|
|
release_email_check.set()
|
|
setup_process.join(timeout=10)
|
|
create_process.join(timeout=10)
|
|
assert setup_process.exitcode == 0
|
|
assert create_process.exitcode == 0
|
|
results = [result_queue.get(timeout=2) for _ in range(2)]
|
|
assert [status for status, _value in results].count("ok") == 1
|
|
assert [status for status, _value in results].count("error") == 1
|
|
assert {value for status, value in results if status == "error"} == {
|
|
"a user with this email already exists"
|
|
}
|
|
finally:
|
|
release_email_check.set()
|
|
for process in (setup_process, create_process):
|
|
if process is not None and process.is_alive():
|
|
process.terminate()
|
|
if process is not None:
|
|
process.join(timeout=2)
|
|
|
|
|
|
def test_complete_setup_is_atomic_across_worker_processes(user_store):
|
|
"""The one-time setup claim must hold when workers do not share Python locks."""
|
|
user_store.create_user(
|
|
org_id="org-default",
|
|
username="setup-process-race",
|
|
password="old-password",
|
|
name="Setup Process Race",
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
|
|
context = mp.get_context("fork")
|
|
barrier = context.Barrier(2)
|
|
result_queue = context.Queue()
|
|
data_dir = str(user_store.users.root.parent)
|
|
processes = [
|
|
context.Process(
|
|
target=_complete_setup_in_process,
|
|
args=(data_dir, f"process-{index}@example.com", barrier, result_queue),
|
|
)
|
|
for index in (1, 2)
|
|
]
|
|
for process in processes:
|
|
process.start()
|
|
try:
|
|
for process in processes:
|
|
process.join(timeout=15)
|
|
|
|
assert all(not process.is_alive() for process in processes)
|
|
assert all(process.exitcode == 0 for process in processes)
|
|
results = [result_queue.get(timeout=2) for _ in processes]
|
|
assert [status for status, _value in results].count("ok") == 1
|
|
assert [status for status, _value in results].count("error") == 1
|
|
assert {value for status, value in results if status == "error"} == {
|
|
"setup already completed"
|
|
}
|
|
finally:
|
|
for process in processes:
|
|
if process.is_alive():
|
|
process.terminate()
|
|
process.join(timeout=2)
|
|
|
|
|
|
def test_register_never_rearms_bootstrap_promotion_under_concurrency(app, monkeypatch):
|
|
"""Deleting every user cannot re-arm public super-admin promotion."""
|
|
store = app.extensions["user_store"]
|
|
store.users.delete("admin")
|
|
|
|
original_create_user = store.create_user
|
|
entered_create = threading.Barrier(2)
|
|
|
|
def synchronized_create_user(*args, **kwargs):
|
|
try:
|
|
entered_create.wait(timeout=1)
|
|
except threading.BrokenBarrierError:
|
|
pass
|
|
return original_create_user(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(store, "create_user", synchronized_create_user)
|
|
|
|
def register(index: int) -> dict:
|
|
with app.test_client() as local_client:
|
|
response = local_client.post(
|
|
"/api/auth/register",
|
|
json={
|
|
"username": f"first-register-{index}",
|
|
"password": "first-register-password",
|
|
"email": f"first-register-{index}@example.com",
|
|
"accepted_terms": True,
|
|
},
|
|
)
|
|
assert response.status_code == 201, response.get_json()
|
|
return response.get_json()
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
responses = list(pool.map(register, (1, 2)))
|
|
|
|
roles = [response["user"]["role"] for response in responses]
|
|
assert roles.count("super_admin") == 0
|
|
assert roles.count("user") == 2
|
|
|
|
|
|
def test_register_requires_and_records_terms(client, user_store):
|
|
missing_terms = client.post(
|
|
"/api/auth/register",
|
|
json={
|
|
"username": "terms-required-user",
|
|
"password": "terms-required-password",
|
|
"email": "terms-required@example.com",
|
|
"accepted_terms": False,
|
|
},
|
|
)
|
|
|
|
assert missing_terms.status_code == 400, missing_terms.get_json()
|
|
assert user_store.get_user_or_none("terms-required-user") is None
|
|
|
|
accepted = client.post(
|
|
"/api/auth/register",
|
|
json={
|
|
"username": "terms-accepted-user",
|
|
"password": "terms-accepted-password",
|
|
"email": "terms-accepted@example.com",
|
|
"accepted_terms": True,
|
|
},
|
|
)
|
|
|
|
assert accepted.status_code == 201, accepted.get_json()
|
|
public_user = accepted.get_json()["user"]
|
|
assert public_user["accepted_terms"] is True
|
|
assert isinstance(public_user["accepted_terms_at"], str)
|
|
persisted = user_store.get_user("terms-accepted-user")
|
|
assert persisted["accepted_terms"] is True
|
|
assert persisted["accepted_terms_at"] == public_user["accepted_terms_at"]
|
|
|
|
|
|
def test_register_rejects_placeholder_default_org(client, user_store, monkeypatch):
|
|
from app.config import Config
|
|
|
|
monkeypatch.setattr(Config, "OAUTH_DEFAULT_ORG", "replace_me")
|
|
|
|
response = client.post(
|
|
"/api/auth/register",
|
|
json={
|
|
"username": "placeholder-org-user",
|
|
"password": "placeholder-org-password",
|
|
"email": "placeholder-org@example.com",
|
|
"accepted_terms": True,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 503, response.get_json()
|
|
assert user_store.get_user_or_none("placeholder-org-user") is None
|
|
|
|
|
|
def test_setup_records_an_explicit_utc_consent_timestamp(
|
|
client, user_store, login, monkeypatch
|
|
):
|
|
from datetime import datetime, timezone
|
|
|
|
from app.api import auth_routes
|
|
|
|
fixed_now = datetime(2026, 8, 24, 6, 30, 0, tzinfo=timezone.utc)
|
|
monkeypatch.setattr(auth_routes, "utc_now", lambda: fixed_now, raising=False)
|
|
user_store.create_user(
|
|
org_id="org-default",
|
|
username="utc-setup-user",
|
|
password="utc-setup-old-password",
|
|
name="UTC Setup User",
|
|
role="user",
|
|
must_setup=True,
|
|
)
|
|
token = login("utc-setup-user", "utc-setup-old-password")["token"]
|
|
|
|
response = client.post(
|
|
"/api/auth/setup",
|
|
json={
|
|
"email": "utc-setup@example.com",
|
|
"password": "utc-setup-new-password",
|
|
"accepted_terms": True,
|
|
},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
persisted = user_store.get_user("utc-setup-user")
|
|
assert persisted["accepted_terms_at"] == fixed_now.isoformat() |