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