Release 0.9.0: admin user-management page + new-request notifications

This commit is contained in:
Ben Stull
2026-05-28 03:39:25 -07:00
parent de28272914
commit 7872b921ed
14 changed files with 1204 additions and 119 deletions
+425
View File
@@ -0,0 +1,425 @@
"""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/<id>/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/<id>/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/<id>/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