"""Admin surface: env-allowlist gate, roles + lab-tier cap bypass, password reset, user deletion cascade, failed-run requeue, verification queue.""" import io import time import pytest from fastapi.testclient import TestClient from app.main import app ADMIN_EMAIL = "admin@lab.test" @pytest.fixture() def client(monkeypatch): monkeypatch.setenv("ADMIN_EMAILS", f"{ADMIN_EMAIL}, other-admin@lab.test") # Invite links are on hold by default (2026-07-31); these tests cover the # dormant machinery, so switch it on. The on-hold behavior itself is # covered by test_invites_on_hold_by_default. monkeypatch.setenv("CCR_INVITES_ENABLED", "1") with TestClient(app) as c: yield c def register(client, email, name="User"): resp = client.post( "/api/auth/register", json={"email": email, "password": "password123", "name": name} ) assert resp.status_code == 201 return resp.json() def sign_in_as(client, email, name="User"): """Login-or-register: the test DB persists across tests in this module.""" client.post("/api/auth/logout") resp = client.post("/api/auth/login", json={"email": email, "password": "password123"}) if resp.status_code != 200: register(client, email, name) def csv_rows(n: int) -> bytes: return ("text\n" + "\n".join(f"sample sentence number {i} here" for i in range(n))).encode() def upload(client, project_id, name, payload): return client.post( f"/api/projects/{project_id}/corpora", files={"file": (name, io.BytesIO(payload), "text/csv")}, ) def run_job(client, project_id, corpus_id, construct_id): return client.post( "/api/jobs", json={ "project_id": project_id, "corpus_id": corpus_id, "construct_id": construct_id, "text_column": "text", "model_name": "fake-deterministic", }, ) def wait_for_job(client, job_id, timeout=10.0): deadline = time.time() + timeout while time.time() < deadline: job = client.get(f"/api/jobs/{job_id}").json() if job["status"] in ("completed", "failed"): return job time.sleep(0.05) raise TimeoutError(job_id) # ------------------------------------------------------------------ access def test_admin_requires_allowlisted_signed_in_user(client): assert client.get("/api/admin/overview").status_code == 403 # anonymous register(client, "normal@lab.test") assert client.get("/api/admin/overview").status_code == 403 # signed in, not allowlisted me = client.get("/api/auth/me").json() assert me["is_admin"] is False client.post("/api/auth/logout") register(client, ADMIN_EMAIL, "Admin") me = client.get("/api/auth/me").json() assert me["is_admin"] is True resp = client.get("/api/admin/overview") assert resp.status_code == 200 assert resp.json()["users"] >= 2 def test_admin_page_route_serves_ui(client): resp = client.get("/admin") assert resp.status_code == 200 assert "text/html" in resp.headers["content-type"] # ------------------------------------------------------ roles and lab tier def test_lab_role_bypasses_saved_run_cap(client, monkeypatch): monkeypatch.setenv("CCR_USER_MAX_SAVED_RUNS", "1") register(client, "phd@lab.test", "PhD") project = client.post("/api/projects", json={"name": "LabTier"}).json() corpus = upload(client, project["id"], "c.csv", csv_rows(5)).json() construct = client.get("/api/constructs").json()[0] resp = run_job(client, project["id"], corpus["id"], construct["id"]) assert resp.status_code == 201 wait_for_job(client, resp.json()["id"]) assert run_job(client, project["id"], corpus["id"], construct["id"]).status_code == 409 phd_id = next( u["id"] for u in _as_admin(client).get("/api/admin/users").json() if u["email"] == "phd@lab.test" ) resp = client.post(f"/api/admin/users/{phd_id}/role", json={"role": "lab"}) assert resp.status_code == 200 and resp.json()["role"] == "lab" client.post("/api/auth/logout") client.post("/api/auth/login", json={"email": "phd@lab.test", "password": "password123"}) me = client.get("/api/auth/me").json() assert me["role"] == "lab" and me["usage"]["max_saved_runs"] is None resp = run_job(client, project["id"], corpus["id"], construct["id"]) assert resp.status_code == 201 # cap no longer applies def _as_admin(client): sign_in_as(client, ADMIN_EMAIL, "Admin") return client def _user_id(client, email): return next(u["id"] for u in client.get("/api/admin/users").json() if u["email"] == email) # ------------------------------------------- four tiers (PI decision 2026-07-22) def test_staff_roles_get_admin_surface_and_unlimited_runs(client, monkeypatch): monkeypatch.setenv("CCR_USER_MAX_SAVED_RUNS", "1") register(client, "newpi@lab.test", "The PI") me = client.get("/api/auth/me").json() assert me["role"] == "external" and me["is_admin"] is False # default tier assert me["usage"]["max_saved_runs"] == 1 client.post("/api/auth/logout") _as_admin(client) for role in ("pi", "maintainer", "lab", "external"): # all four grantable resp = client.post(f"/api/admin/users/{_user_id(client, 'newpi@lab.test')}/role", json={"role": role}) assert resp.status_code == 200 and resp.json()["role"] == role client.post(f"/api/admin/users/{_user_id(client, 'newpi@lab.test')}/role", json={"role": "pi"}) assert client.post(f"/api/admin/users/{_user_id(client, 'newpi@lab.test')}/role", json={"role": "owner"}).status_code == 400 overview = client.get("/api/admin/overview").json() assert overview["users_by_role"]["pi"] >= 1 client.post("/api/auth/logout") client.post("/api/auth/login", json={"email": "newpi@lab.test", "password": "password123"}) me = client.get("/api/auth/me").json() assert me["role"] == "pi" assert me["is_admin"] is True # staff role grants the admin surface... assert me["usage"]["max_saved_runs"] is None # ...and unlimited saved runs assert client.get("/api/admin/overview").status_code == 200 def test_maintainer_cannot_escalate_or_touch_staff(client): _as_admin(client) for email, role in (("mnt@lab.test", "maintainer"), ("boss@lab.test", "pi"), ("phd2@lab.test", "external")): sign_in_as(client, email) client.post("/api/auth/logout") _as_admin(client) client.post(f"/api/admin/users/{_user_id(client, email)}/role", json={"role": role}) boss_id, phd_id = _user_id(client, "boss@lab.test"), _user_id(client, "phd2@lab.test") client.post("/api/auth/logout") client.post("/api/auth/login", json={"email": "mnt@lab.test", "password": "password123"}) # operational surface works, and lab/external grants are allowed... assert client.get("/api/admin/overview").status_code == 200 assert client.post(f"/api/admin/users/{phd_id}/role", json={"role": "lab"}).status_code == 200 # ...but staff grants and any action on a staff account are env-admin only assert client.post(f"/api/admin/users/{phd_id}/role", json={"role": "maintainer"}).status_code == 403 assert client.post(f"/api/admin/users/{boss_id}/role", json={"role": "external"}).status_code == 403 assert client.post(f"/api/admin/users/{boss_id}/reset-password").status_code == 403 assert client.delete(f"/api/admin/users/{boss_id}").status_code == 403 client.post("/api/auth/logout") _as_admin(client) # env admin CAN demote a staff account assert client.post(f"/api/admin/users/{boss_id}/role", json={"role": "lab"}).status_code == 200 # --------------------------------------------------------- reset + delete def test_password_reset_issues_working_temp_password(client): register(client, "forgetful@lab.test") sign_in_as(client, ADMIN_EMAIL, "Admin") uid = next( u["id"] for u in client.get("/api/admin/users").json() if u["email"] == "forgetful@lab.test" ) temp = client.post(f"/api/admin/users/{uid}/reset-password").json()["temporary_password"] client.post("/api/auth/logout") bad = client.post( "/api/auth/login", json={"email": "forgetful@lab.test", "password": "password123"} ) assert bad.status_code == 401 # old password dead good = client.post( "/api/auth/login", json={"email": "forgetful@lab.test", "password": temp} ) assert good.status_code == 200 def test_password_reset_refused_for_google_account(client, monkeypatch): from app import auth_google monkeypatch.setattr( auth_google, "exchange", lambda code, verifier: {"email": "googler@lab.test", "name": "Googler"}, ) monkeypatch.setenv("SUPABASE_URL", "https://x.supabase.co") monkeypatch.setenv("SUPABASE_ANON_KEY", "k") from app import auth client.cookies.set(auth_google.VERIFIER_COOKIE, auth.sign_payload({"v": "v"})) client.get("/api/auth/google/callback?code=c", follow_redirects=False) client.post("/api/auth/logout") sign_in_as(client, ADMIN_EMAIL, "Admin") uid = next( u["id"] for u in client.get("/api/admin/users").json() if u["email"] == "googler@lab.test" ) assert any( u["email"] == "googler@lab.test" and u["google_only"] for u in client.get("/api/admin/users").json() ) resp = client.post(f"/api/admin/users/{uid}/reset-password") assert resp.status_code == 400 and "Google" in resp.json()["detail"] def test_delete_user_cascades_and_protects_self(client): register(client, "doomed@lab.test") project = client.post("/api/projects", json={"name": "DoomedData"}).json() upload(client, project["id"], "c.csv", csv_rows(5)) client.post("/api/auth/logout") sign_in_as(client, ADMIN_EMAIL, "Admin") users = client.get("/api/admin/users").json() doomed_id = next(u["id"] for u in users if u["email"] == "doomed@lab.test") my_id = next(u["id"] for u in users if u["email"] == ADMIN_EMAIL) assert client.delete(f"/api/admin/users/{my_id}").status_code == 400 # self-protect assert client.delete(f"/api/admin/users/{doomed_id}").status_code == 204 assert all( u["email"] != "doomed@lab.test" for u in client.get("/api/admin/users").json() ) assert client.get(f"/api/projects/{project['id']}/corpora").status_code in (403, 404) # ------------------------------------------------------------ failed runs def test_failed_job_requeue(client, monkeypatch): sign_in_as(client, ADMIN_EMAIL, "Admin") project = client.post("/api/projects", json={"name": "FailThenFix"}).json() corpus = upload(client, project["id"], "c.csv", csv_rows(5)).json() construct = client.get("/api/constructs").json()[0] # Force a failure: point the job at a column that exists in the DB row but # sabotage the stored file? Simpler: monkeypatch the engine to raise once. from app import jobs as jobs_module original = jobs_module.run_ccr calls = {"n": 0} def flaky(*args, **kwargs): calls["n"] += 1 if calls["n"] == 1: raise RuntimeError("synthetic failure for admin requeue test") return original(*args, **kwargs) monkeypatch.setattr(jobs_module, "run_ccr", flaky) job = run_job(client, project["id"], corpus["id"], construct["id"]).json() assert wait_for_job(client, job["id"])["status"] == "failed" failed = client.get("/api/admin/jobs/failed").json() entry = next(f for f in failed if f["id"] == job["id"]) assert entry["corpus_file_available"] is True assert "synthetic failure" in entry["error_tail"] assert client.post(f"/api/admin/jobs/{job['id']}/requeue").status_code == 200 assert wait_for_job(client, job["id"])["status"] == "completed" # ------------------------------------------------------------ verification def _make_role(client, email, role): """Register (if needed) and set a role, as the env admin; leaves the client signed in as that user.""" sign_in_as(client, email) client.post("/api/auth/logout") _as_admin(client) resp = client.post(f"/api/admin/users/{_user_id(client, email)}/role", json={"role": role}) assert resp.status_code == 200, resp.json() client.post("/api/auth/logout") client.post("/api/auth/login", json={"email": email, "password": "password123"}) def test_verification_is_the_maintainers_job(client): """PI decision 2026-07-22: maintainers verify; PI/admin see the queue read-only (the trail then names the responsible RA).""" sign_in_as(client, ADMIN_EMAIL, "Admin") queue = client.get("/api/admin/constructs?status=needs_verification").json() assert len(queue) > 0 target = queue[0] # env admin: queue visible, verification action refused resp = client.post( f"/api/admin/constructs/{target['id']}/verification", json={"status": "verified"} ) assert resp.status_code == 403 and "maintainer" in resp.json()["detail"] _make_role(client, "ra@lab.test", "maintainer") resp = client.post( f"/api/admin/constructs/{target['id']}/verification", json={"status": "verified"} ) assert resp.status_code == 200 and resp.json()["verification_status"] == "verified" remaining = client.get("/api/admin/constructs?status=needs_verification").json() assert all(c["id"] != target["id"] for c in remaining) # visible to regular users too (flag disappears in the picker/details) pub = next(c for c in client.get("/api/constructs").json() if c["id"] == target["id"]) assert pub["verification_status"] == "verified" # ------------------------------------- pre-login access + audit (PI email 2026-07-22) def test_preassigned_role_lands_on_first_signin(client): """The Dr. Chen scenario: full credentials bound to an email before the account exists; first sign-in (password or Google) claims them.""" _as_admin(client) resp = client.post( "/api/admin/role-assignments", json={"email": "collaborator@other-lab.edu", "role": "maintainer"}, ) assert resp.status_code == 201 pending = client.get("/api/admin/role-assignments").json() entry = next(a for a in pending if a["email"] == "collaborator@other-lab.edu") assert entry["role"] == "maintainer" and entry["claimed_at"] is None client.post("/api/auth/logout") register(client, "collaborator@other-lab.edu", "Dr. Chen") me = client.get("/api/auth/me").json() assert me["role"] == "maintainer" and me["is_admin"] is True assert me["usage"]["max_saved_runs"] is None client.post("/api/auth/logout") _as_admin(client) claimed = next( a for a in client.get("/api/admin/role-assignments").json() if a["email"] == "collaborator@other-lab.edu" ) assert claimed["claimed_at"] is not None # existing accounts are managed in the Users table, not via pre-assignment resp = client.post( "/api/admin/role-assignments", json={"email": "collaborator@other-lab.edu", "role": "lab"}, ) assert resp.status_code == 409 def test_maintainer_cannot_preassign_staff(client): _make_role(client, "mnt3@lab.test", "maintainer") assert client.post( "/api/admin/role-assignments", json={"email": "someone@new.edu", "role": "pi"}, ).status_code == 403 assert client.post( "/api/admin/role-assignments", json={"email": "someone@new.edu", "role": "lab"}, ).status_code == 201 def test_invite_link_grants_role_on_register(client, monkeypatch): _as_admin(client) invite = client.post("/api/admin/invites", json={"role": "lab"}).json() assert invite["role"] == "lab" and invite["token"] and invite["status"] == "active" # staff can never be invited by bearer link assert client.post("/api/admin/invites", json={"role": "maintainer"}).status_code == 400 client.post("/api/auth/logout") resp = client.post("/api/auth/register", json={ "email": "invited@lab.test", "password": "password123", "name": "Invited", "invite_token": invite["token"], }) assert resp.status_code == 201 me = client.get("/api/auth/me").json() assert me["role"] == "lab" and me["usage"]["max_saved_runs"] is None client.post("/api/auth/logout") # who used the link is visible on the invite row _as_admin(client) row = next(i for i in client.get("/api/admin/invites").json() if i["id"] == invite["id"]) assert [r["email"] for r in row["redemptions"]] == ["invited@lab.test"] client.post("/api/auth/logout") # dead/garbage tokens refuse registration instead of silently demoting resp = client.post("/api/auth/register", json={ "email": "invited2@lab.test", "password": "password123", "name": "Invited2", "invite_token": "garbage.token", }) assert resp.status_code == 400 and "invite" in resp.json()["detail"].lower() monkeypatch.setenv("CCR_INVITE_TTL_DAYS", "-1") # mint an already-expired invite _as_admin(client) expired = client.post("/api/admin/invites", json={"role": "lab"}).json() client.post("/api/auth/logout") resp = client.post("/api/auth/register", json={ "email": "invited3@lab.test", "password": "password123", "name": "Invited3", "invite_token": expired["token"], }) assert resp.status_code == 400 def test_revoked_invite_stops_working(client): _as_admin(client) invite = client.post("/api/admin/invites", json={"role": "lab"}).json() assert client.delete(f"/api/admin/invites/{invite['id']}").status_code == 204 row = next(i for i in client.get("/api/admin/invites").json() if i["id"] == invite["id"]) assert row["status"] == "revoked" and row["token"] == "" # dead links aren't re-copyable client.post("/api/auth/logout") resp = client.post("/api/auth/register", json={ "email": "late@lab.test", "password": "password123", "name": "Late", "invite_token": invite["token"], }) assert resp.status_code == 400 and "revoked" in resp.json()["detail"] def test_invites_on_hold_by_default(client, monkeypatch): """Invite links are dormant unless CCR_INVITES_ENABLED=1 (2026-07-31): creation is refused, previously minted tokens stop redeeming, and the overview flag tells the admin UI to hide the section. Pre-assigned roles keep working - they are the supported onboarding path.""" _as_admin(client) live = client.post("/api/admin/invites", json={"role": "lab"}).json() # minted while enabled monkeypatch.setenv("CCR_INVITES_ENABLED", "0") assert client.get("/api/admin/overview").json()["invites_enabled"] is False resp = client.post("/api/admin/invites", json={"role": "lab"}) assert resp.status_code == 409 and "on hold" in resp.json()["detail"] client.post("/api/auth/logout") resp = client.post("/api/auth/register", json={ "email": "held@lab.test", "password": "password123", "name": "Held", "invite_token": live["token"], }) assert resp.status_code == 400 and "on hold" in resp.json()["detail"] # pre-assignment still lands the role even while links are frozen _as_admin(client) client.post("/api/admin/role-assignments", json={"email": "held@lab.test", "role": "lab"}) client.post("/api/auth/logout") resp = client.post("/api/auth/register", json={ "email": "held@lab.test", "password": "password123", "name": "Held", }) assert resp.status_code == 201 assert client.get("/api/auth/me").json()["role"] == "lab" client.post("/api/auth/logout") def test_audit_trail_records_and_is_pi_only(client): _as_admin(client) audit = client.get("/api/admin/audit") assert audit.status_code == 200 actions = {(a["action"], a["target"]) for a in audit.json()} assert ("role_preassigned", "collaborator@other-lab.edu") in actions assert ("role_claimed", "collaborator@other-lab.edu") in actions assert ("invite_redeemed", "invited@lab.test") in actions assert any(a[0] == "set_verification" for a in actions) client.post("/api/auth/logout") # maintainers work the cards but don't get top-down oversight client.post("/api/auth/login", json={"email": "mnt3@lab.test", "password": "password123"}) assert client.get("/api/admin/audit").status_code == 403 def test_pi_role_holds_escalation_rights_and_no_self_change(client): _make_role(client, "pi2@lab.test", "pi") me = client.get("/api/auth/me").json() assert me["role"] == "pi" and me["is_admin"] is True sign_in_as(client, "newbie@lab.test") client.post("/api/auth/logout") client.post("/api/auth/login", json={"email": "pi2@lab.test", "password": "password123"}) newbie_id = _user_id(client, "newbie@lab.test") # a PI-by-role can mint staff and see the audit trail - no env entry needed assert client.post(f"/api/admin/users/{newbie_id}/role", json={"role": "maintainer"}).status_code == 200 assert client.get("/api/admin/audit").status_code == 200 # but nobody, PI included, can change their own role my_id = _user_id(client, "pi2@lab.test") resp = client.post(f"/api/admin/users/{my_id}/role", json={"role": "external"}) assert resp.status_code == 400 and "own role" in resp.json()["detail"]