Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """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" | |
| 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"] | |