"""End-to-end integration tests for v0.9.0's admin user-management page and new-beta-request notifications (roadmap item #7, §6.1 / §15). The release lands two halves of the same surface: * **Admin notification on new beta request.** When a pending user submits `POST /api/auth/me/beta-request`, every owner/admin receives a `new_beta_request` notification (the §15 substrate insert lands the row; the §15.4 email path dispatches subject to the recipient's `email_admin_actionable` toggle). * **Admin user-management surface** at `/admin/users`. The `GET /api/admin/users` listing carries every user with their permission_state, profile fields, sign-up reason, and decision audit. The new `POST /api/admin/users//permission` endpoint flips the column and writes a `permission_events` row. The tests prove: * The first beta-request submission fans a `new_beta_request` row out to every admin/owner (and not to the requester themselves). The row carries the captured profile in `payload.extras`. * Re-submitting the form from the same pending user doesn't re-fan (we only notify on the row's first complete state). * `GET /api/admin/users` carries the v0.9.0 columns (permission_state, first/last/reason, decided_by). * `POST /api/admin/users//permission` flips the state, stamps decided_by/at, and writes a `permission_events` row. * The endpoint refuses self-flip (422) and refuses non-admin callers (403). * The endpoint accepts only the three valid states (422 on anything else). """ from __future__ import annotations from test_propose_vertical import ( # noqa: F401 — fixtures land via import FakeGitea, app_with_fake_gitea, provision_user_row, sign_in_as, tmp_env, ) def _reset_outbound(): from app import email as email_mod email_mod.reset_sent_envelopes() def _outbound_otc_codes(to_address: str | None = None) -> list[str]: from app import email as email_mod out = [] for env in email_mod.sent_envelopes(): if env.get("kind") != "otc": continue if to_address is not None and env["to"] != to_address: continue for line in env["body"].splitlines(): tok = line.strip() if tok.isdigit() and len(tok) == 6: out.append(tok) break return out def _provision_pending_user(client, email: str) -> int: """Sign in a fresh OTC user (lands `pending`) and return their user_id.""" from app import db _reset_outbound() client.post("/auth/otc/request", json={"email": email}) code = _outbound_otc_codes(email)[-1] client.post("/auth/otc/verify", json={"email": email, "code": code}) row = db.conn().execute( "SELECT id FROM users WHERE email = ? COLLATE NOCASE", (email,) ).fetchone() return row["id"] # --------------------------------------------------------------------------- # Admin notification on beta-request submission # --------------------------------------------------------------------------- def test_beta_request_submission_notifies_every_admin(app_with_fake_gitea): """First-time submission of a beta-request fans a notification out to every owner and admin. The requester themselves never receives a row (filtered out by user_id even if they happened to be in the admin set, which they aren't in practice — fresh OTC users are `contributor`+`pending`).""" from fastapi.testclient import TestClient from app import db app, _fake = app_with_fake_gitea with TestClient(app) as client: # Provision two admins and one owner so the fan-out has multiple # targets. The OWNER_GITEA_LOGIN-derived ownership doesn't fire # here (no OAuth round-trip in this path); we seed the role # directly. provision_user_row(user_id=10, login="ownerzero", role="owner") provision_user_row(user_id=11, login="admin_one", role="admin") provision_user_row(user_id=12, login="admin_two", role="admin") provision_user_row(user_id=13, login="contrib_one", role="contributor") # Sign in a fresh OTC user → permission_state='pending'. requester_id = _provision_pending_user(client, "newbie@example.com") # Capture-form submit. r = client.post( "/api/auth/me/beta-request", json={ "first_name": "Newt", "last_name": "Newcomer", "beta_request_reason": "I want to write the Human RFC.", }, ) assert r.status_code == 200, r.text # Every owner + admin gets a `new_beta_request` notification. # The contributor (id=13) does not. The requester (whoever id # they got) does not. rows = db.conn().execute( """ SELECT recipient_user_id, event_kind, actor_user_id, payload FROM notifications WHERE event_kind = 'new_beta_request' """ ).fetchall() recipients = sorted(r["recipient_user_id"] for r in rows) assert recipients == [10, 11, 12], f"unexpected recipients: {recipients}" # Actor is the requester (§15.9: never the bot). for r in rows: assert r["actor_user_id"] == requester_id import json as _json extras = _json.loads(r["payload"]) assert extras["requester_first_name"] == "Newt" assert extras["requester_last_name"] == "Newcomer" assert extras["requester_email"] == "newbie@example.com" def test_beta_request_resubmit_does_not_re_notify(app_with_fake_gitea): """Once a user has completed the capture form, re-submitting it (the endpoint is idempotent for pending users) must not re-fan a fresh notification to every admin — that would carpet-bomb the inbox on every typo correction.""" from fastapi.testclient import TestClient from app import db app, _fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=20, login="adminzero", role="admin") _provision_pending_user(client, "carpet@example.com") body = { "first_name": "Carpet", "last_name": "Bomb", "beta_request_reason": "first draft", } r1 = client.post("/api/auth/me/beta-request", json=body) assert r1.status_code == 200 # Re-submit with edited reason — endpoint accepts (idempotent # update), but the admin inbox stays at one row. body2 = dict(body, beta_request_reason="cleaner final draft") r2 = client.post("/api/auth/me/beta-request", json=body2) assert r2.status_code == 200 rows = db.conn().execute( "SELECT COUNT(*) AS n FROM notifications WHERE event_kind = 'new_beta_request'" ).fetchone() assert rows["n"] == 1 def test_beta_request_notification_is_admin_actionable_category(app_with_fake_gitea): """The §15.4 category mapping must route `new_beta_request` to the admin-actionable bucket so the email gate consults `email_admin_actionable` (and skips for non-admin recipients). """ from app import email as email_mod assert email_mod.category_for("new_beta_request", "structural") == "admin-actionable" # --------------------------------------------------------------------------- # /api/admin/users — listing carries the v0.9.0 columns # --------------------------------------------------------------------------- def test_admin_users_listing_carries_permission_columns(app_with_fake_gitea): """The Users tab consumes this shape — confirm every required column is on the response.""" from fastapi.testclient import TestClient from app import db app, _fake = app_with_fake_gitea with TestClient(app) as client: # Seed an admin and a pending user with all the v0.8.0 columns # populated. Direct-DB insert avoids the OTC dance (which would # overwrite the cookie); the test above proves the capture # pathway end-to-end and this one just exercises the listing # surface's shape. provision_user_row(user_id=30, login="ben", role="owner") db.conn().execute( """ INSERT INTO users (id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state, first_name, last_name, beta_request_reason) VALUES (31, NULL, NULL, 'pendinguser@example.com', 'pendinguser', '', 'contributor', 'pending', 'Penn', 'Ding', 'I want in.') """ ) sign_in_as( client, user_id=30, gitea_login="ben", display_name="Ben", role="owner", ) r = client.get("/api/admin/users") assert r.status_code == 200 items = r.json()["items"] assert isinstance(items, list) pending = next( (i for i in items if i["email"] == "pendinguser@example.com"), None, ) assert pending is not None assert pending["permission_state"] == "pending" assert pending["first_name"] == "Penn" assert pending["last_name"] == "Ding" assert pending["beta_request_reason"] == "I want in." assert pending["permission_decided_at"] is None assert pending["permission_decided_by_login"] is None # Pending bucket is listed first (sort order). assert items[0]["permission_state"] == "pending" # --------------------------------------------------------------------------- # /api/admin/users//permission — the flip endpoint # --------------------------------------------------------------------------- def test_permission_flip_grant_promotes_pending_to_granted(app_with_fake_gitea): """The end-to-end gesture: a fresh OTC user lands pending, an admin flips them to granted via the endpoint, the row reflects the new state + decided_by/at, and a `permission_events` audit row lands.""" from fastapi.testclient import TestClient from app import db app, _fake = app_with_fake_gitea with TestClient(app) as client: # Pending user. pending_id = _provision_pending_user(client, "flip@example.com") # Admin acting on them. provision_user_row(user_id=40, login="adminflipper", role="admin") sign_in_as( client, user_id=40, gitea_login="adminflipper", display_name="Admin Flipper", role="admin", ) r = client.post( f"/api/admin/users/{pending_id}/permission", json={"state": "granted"}, ) assert r.status_code == 200, r.text body = r.json() assert body["permission_state"] == "granted" assert body["changed"] is True # Row reflects the new state + decision stamp. row = db.conn().execute( "SELECT permission_state, permission_decided_by, permission_decided_at " "FROM users WHERE id = ?", (pending_id,), ).fetchone() assert row["permission_state"] == "granted" assert row["permission_decided_by"] == 40 assert row["permission_decided_at"] is not None # Audit row landed in permission_events. events = db.conn().execute( """ SELECT actor_user_id, subject_user_id, event_kind FROM permission_events WHERE event_kind = 'permission_granted' """ ).fetchall() assert len(events) == 1 assert events[0]["actor_user_id"] == 40 assert events[0]["subject_user_id"] == pending_id def test_permission_flip_revoke_promotes_granted_to_revoked(app_with_fake_gitea): """Revoke is the symmetric gesture. Used when an account earned a grant then later lost it (§6.1 / `revoked` state).""" from fastapi.testclient import TestClient from app import db app, _fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=50, login="goner", role="contributor") # Default permission_state is 'granted' via the column default. provision_user_row(user_id=51, login="adminrevoker", role="admin") sign_in_as( client, user_id=51, gitea_login="adminrevoker", display_name="Admin Revoker", role="admin", ) r = client.post( "/api/admin/users/50/permission", json={"state": "revoked"}, ) assert r.status_code == 200, r.text row = db.conn().execute( "SELECT permission_state FROM users WHERE id = 50" ).fetchone() assert row["permission_state"] == "revoked" events = db.conn().execute( "SELECT event_kind FROM permission_events " "WHERE event_kind = 'permission_revoked' AND subject_user_id = 50" ).fetchall() assert len(events) == 1 def test_permission_flip_refuses_self(app_with_fake_gitea): """Symmetric to set_mute / set_role: an admin can't self-flip. The state-change channel for one's own grant is somebody else's hand.""" from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=60, login="selfflipper", role="admin") sign_in_as( client, user_id=60, gitea_login="selfflipper", display_name="Self Flipper", role="admin", ) r = client.post( "/api/admin/users/60/permission", json={"state": "revoked"}, ) assert r.status_code == 422 def test_permission_flip_refuses_non_admin(app_with_fake_gitea): """The endpoint is admin-only (§17 admin/* requires require_admin). A contributor caller is refused 403; an anonymous caller 401.""" from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=70, login="target", role="contributor") provision_user_row(user_id=71, login="contrib", role="contributor") sign_in_as( client, user_id=71, gitea_login="contrib", display_name="Contrib", role="contributor", ) r = client.post( "/api/admin/users/70/permission", json={"state": "granted"}, ) assert r.status_code == 403 client.cookies.clear() r = client.post( "/api/admin/users/70/permission", json={"state": "granted"}, ) assert r.status_code == 401 def test_permission_flip_refuses_invalid_state(app_with_fake_gitea): """Pydantic regex pattern refuses anything outside the three canonical states with 422.""" from fastapi.testclient import TestClient app, _fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=80, login="targetx", role="contributor") provision_user_row(user_id=81, login="adminx", role="admin") sign_in_as( client, user_id=81, gitea_login="adminx", display_name="Admin X", role="admin", ) r = client.post( "/api/admin/users/80/permission", json={"state": "banished"}, ) assert r.status_code == 422 def test_permission_flip_no_op_when_state_already_matches(app_with_fake_gitea): """An admin flipping a granted user to granted gets 200 with `changed: false` — no audit row, no decided_at update.""" from fastapi.testclient import TestClient from app import db app, _fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=90, login="alreadygranted", role="contributor") provision_user_row(user_id=91, login="adminN", role="admin") sign_in_as( client, user_id=91, gitea_login="adminN", display_name="Admin N", role="admin", ) before_events = db.conn().execute( "SELECT COUNT(*) AS n FROM permission_events" ).fetchone()["n"] r = client.post( "/api/admin/users/90/permission", json={"state": "granted"}, ) assert r.status_code == 200 body = r.json() assert body["changed"] is False after_events = db.conn().execute( "SELECT COUNT(*) AS n FROM permission_events" ).fetchone()["n"] assert after_events == before_events