Files
sales-trainer/backend/tests/test_auth_security.py
Macky 3c22d88bcd feat: demo SaaS + training flow security hardening (8/8 review gate passed)
- 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.
2026-08-25 06:39:06 +07:00

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