Files
rfc-app/backend/tests/test_admin_create_user_invite_vertical.py
T
Ben Stull 41b0c6af99 Release 0.17.0: admin-create user + invite email (custom message; claim-link claim flow)
Roadmap item #16 / §6.1. From the v0.9.0 /admin/users surface, an
admin can now create a user record before that person has ever
signed in — typing first name, last name, email, role, and an
optional custom message — and the framework sends an invite email
carrying a single-use claim link. The invitee clicks through to
/invites/claim?token=…, the token is consumed, the session is
established, and the user is routed to the passcode-set screen on
first sign-in.

New endpoints:
  * POST /api/admin/users — admin-only; provisions the users row +
    user_invite_tokens row + sends the invite email + writes a
    permission_events row with event_kind='user_invited'.
  * GET  /api/admin/users/invites — admin-only; lists active
    (not-claimed, not-expired) invites with the issuing admin.
  * POST /api/invites/claim — anonymous-reachable; validates the
    token, consumes the row, signs the invitee in (skipping OTC
    per the roadmap — clicking the email link is itself proof of
    email control), returns needs_passcode for the frontend's
    route-onward decision.

Schema: migration slot 019 — user_invite_tokens (id, email, role,
first/last name, custom_message, bcrypt token_hash, expires_at,
created_at, created_by_admin_id, claimed_at, claimed_by_user_id,
invited_user_id). Slot 018 reserved for the parallel #12 release
(per-RFC invitation) shipping in the same wave; distinct table
(rfc_invitations there vs. user_invite_tokens here) so they
coexist cleanly. No users-table changes — the brief floated a
NULL-column discriminator for "(pending invite)" but the existing
users.last_seen_at is NOT NULL with a datetime('now') default, so
the discriminator is the active user_invite_tokens row joined on
invited_user_id; the admin user-listing carries a pending_invite
field populated via that join.

Claim route: frontend /invites/claim?token=… (new
InviteClaim.jsx). Anonymous-reachable; renders "Claim my account"
CTA with an optional v0.11.0-style "trust this device" checkbox,
calls the claim endpoint, routes onward.

Token shape: opaque DB token (256 bits CSPRNG via
secrets.token_urlsafe(32), bcrypt-at-rest), not JWT. Opaque
chosen because admin revocation is then a single SQL UPDATE — JWT
would be stateless but harder to invalidate.

Open-question decisions: immediate-send (no admin-review-then-
send queue; future enhancement), no bulk-invite (deferred to
follow-up; v0.17.0 is one-at-a-time), 7-day expiry as a constant
(INVITE_TOKEN_TTL_DAYS in backend/app/invites.py; env-var
configurability is a §19.2 candidate), OTC skipped on first
sign-in (the token in the email is itself proof of email control;
subsequent sign-ins go through OTC / passcode unchanged).

Refusals on POST /api/admin/users:
  * 422 self-invite (use the role-change channel for self-edits)
  * 409 duplicate email (use the existing grant/role gestures)
  * 422 owner-grant by non-owner admin (§6.1 owner-zero is the
    only owner bootstrap path)
  * 422 pydantic — malformed email / unknown role /
    custom_message > 500 chars
  * 403 non-admin caller / 401 anonymous

15 new backend tests in test_admin_create_user_invite_vertical.py
(happy path, all four refusals, claim with valid / expired /
already-claimed / unknown token, pending-invite badge before and
after claim, listing admin-only). 234 total backend tests pass;
frontend build succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 04:48:02 -07:00

650 lines
24 KiB
Python

"""End-to-end integration tests for v0.17.0's admin-create user +
invite-email + claim-flow vertical (roadmap item #16, §6.1).
The release lands three halves of the same surface:
* **Admin-create user** at `POST /api/admin/users`. The admin types
email, first/last name, role, and an optional custom message. The
framework provisions the invitee `users` row (granted, with the
chosen role) and writes a `user_invite_tokens` row carrying the
bcrypt-hashed opaque token. The "pending invite" discriminator is
the active `user_invite_tokens` row joined on `invited_user_id`,
not a NULL column on `users` (the existing `last_seen_at` column
is NOT NULL). An invite email dispatches via the existing SMTP
relay.
* **Pending-invite admin listing** at `GET /api/admin/users/invites`.
Lists active (not claimed, not expired) invites for the admin's
"I sent these but they haven't been claimed yet" view.
* **Claim** at `POST /api/invites/claim`. The invitee POSTs the token
they got via email; the framework verifies, marks the row claimed,
signs them in (skipping OTC on first sign-in per the roadmap), and
returns a `needs_passcode` hint for the frontend to route to the
passcode-set screen.
The tests prove:
* The happy path: admin creates → invite row + email envelope land →
invitee claims with the token → session is established.
* Non-admin caller is refused 403.
* Self-invite is refused 422.
* Duplicate email is refused 409.
* Owner-grant by non-owner is refused 422.
* Malformed role is refused 422 (pydantic regex).
* Custom message over 500 chars is refused 422 (pydantic max_length).
* Claim with valid token: signs in + marks row claimed.
* Claim with expired token: HTTP 410.
* Claim with already-claimed token: HTTP 410.
* Claim with unknown token: HTTP 400.
* The admin-create gesture writes a `permission_events` row with
event_kind='user_invited'.
* The user listing surfaces the `pending_invite` field for invited-
but-not-yet-claimed users, and clears it after claim.
"""
from __future__ import annotations
import json
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_invite_envelopes(to_address: str | None = None) -> list[dict]:
"""Pull the invite-kind envelopes off the shared notifier buffer.
Mirrors the OTC code-extraction helper in
test_admin_users_vertical.py — invite emails land in the same
`_SENT` buffer with `kind='invite'`.
"""
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "invite":
continue
if to_address is not None and env["to"] != to_address:
continue
out.append(env)
return out
def _extract_claim_url(envelope: dict) -> str:
"""Pull the claim URL out of the invite email body."""
for line in envelope["body"].splitlines():
line = line.strip()
if line.startswith("http") and "/invites/claim" in line:
return line
raise AssertionError(f"no claim URL in envelope body: {envelope['body']!r}")
def _extract_claim_token(envelope: dict) -> str:
"""Pull the `token` query-string param out of the claim URL."""
from urllib.parse import urlparse, parse_qs
url = _extract_claim_url(envelope)
qs = parse_qs(urlparse(url).query)
return qs["token"][0]
# ---------------------------------------------------------------------------
# Admin create + invite — happy path
# ---------------------------------------------------------------------------
def test_admin_create_user_invite_happy_path(app_with_fake_gitea):
"""Admin creates → user row + invite-token row + email envelope all
land; the response carries the created ids and the inviter is the
admin who issued the gesture."""
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=100, login="adminzero", role="admin")
sign_in_as(
client, user_id=100, gitea_login="adminzero",
display_name="Admin Zero", role="admin",
email="adminzero@test",
)
_reset_outbound()
r = client.post(
"/api/admin/users",
json={
"email": "invitee@example.com",
"first_name": "Inv",
"last_name": "Tee",
"role": "contributor",
"custom_message": "We chatted at the conference — welcome!",
},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["email"] == "invitee@example.com"
assert body["role"] == "contributor"
assert body["invite_id"] > 0
assert body["invited_user_id"] > 0
# User row exists with the chosen role + granted. The "pending
# invite" discriminator is the active `user_invite_tokens` row,
# not a NULL column on `users` — see the invites.create_invite
# docstring for the reasoning.
row = db.conn().execute(
"SELECT role, permission_state, first_name, last_name "
"FROM users WHERE email = ? COLLATE NOCASE",
("invitee@example.com",),
).fetchone()
assert row is not None
assert row["role"] == "contributor"
assert row["permission_state"] == "granted"
assert row["first_name"] == "Inv"
assert row["last_name"] == "Tee"
# Invite-token row exists with the matching ids and the custom
# message persisted verbatim.
invite = db.conn().execute(
"SELECT email, role, custom_message, created_by_admin_id, "
"invited_user_id, claimed_at FROM user_invite_tokens WHERE id = ?",
(body["invite_id"],),
).fetchone()
assert invite is not None
assert invite["email"] == "invitee@example.com"
assert invite["role"] == "contributor"
assert invite["custom_message"] == "We chatted at the conference — welcome!"
assert invite["created_by_admin_id"] == 100
assert invite["invited_user_id"] == body["invited_user_id"]
assert invite["claimed_at"] is None
# Email envelope landed with the invite kind and embeds the
# custom message + claim URL. The inviter display name comes
# off the DB row (which provision_user_row sets to
# login.capitalize()), not the sign_in_as cookie payload.
envelopes = _outbound_invite_envelopes(to_address="invitee@example.com")
assert len(envelopes) == 1
env = envelopes[0]
assert "Adminzero" in env["subject"] or "Adminzero" in env["body"]
assert "We chatted at the conference — welcome!" in env["body"]
# Claim URL is well-formed.
url = _extract_claim_url(env)
assert "/invites/claim?token=" in url
# `permission_events` row landed with event_kind='user_invited'.
ev = db.conn().execute(
"SELECT actor_user_id, subject_user_id, event_kind, details "
"FROM permission_events WHERE event_kind = 'user_invited'"
).fetchall()
assert len(ev) == 1
assert ev[0]["actor_user_id"] == 100
assert ev[0]["subject_user_id"] == body["invited_user_id"]
details = json.loads(ev[0]["details"])
assert details["email"] == "invitee@example.com"
assert details["role"] == "contributor"
# ---------------------------------------------------------------------------
# Refusals on the admin-create endpoint
# ---------------------------------------------------------------------------
def test_admin_create_user_invite_refuses_non_admin(app_with_fake_gitea):
"""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=110, login="contrib", role="contributor")
sign_in_as(
client, user_id=110, gitea_login="contrib",
display_name="Contrib", role="contributor",
)
r = client.post(
"/api/admin/users",
json={"email": "x@y.com", "role": "contributor"},
)
assert r.status_code == 403, r.text
client.cookies.clear()
r = client.post(
"/api/admin/users",
json={"email": "x@y.com", "role": "contributor"},
)
assert r.status_code == 401
def test_admin_create_user_invite_refuses_self_email(app_with_fake_gitea):
"""An admin trying to invite their own email is refused 422 —
self-invite is the wrong channel; the role-change endpoint exists
for self-edits."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=120, login="adm", role="admin")
# Manually set the admin's email since provision_user_row's
# fixture uses login@test; this is what we'll try to self-invite.
from app import db
db.conn().execute(
"UPDATE users SET email = ? WHERE id = ?",
("selfinviter@example.com", 120),
)
sign_in_as(
client, user_id=120, gitea_login="adm",
display_name="Adm", role="admin",
email="selfinviter@example.com",
)
r = client.post(
"/api/admin/users",
json={
"email": "selfinviter@example.com",
"role": "contributor",
},
)
assert r.status_code == 422, r.text
assert "yourself" in r.json()["detail"].lower()
def test_admin_create_user_invite_refuses_duplicate_email(app_with_fake_gitea):
"""An admin trying to invite an email that already maps to a users
row is refused 409 — the existing role / grant gestures are the
right surface for an existing user."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=130, login="adminD", role="admin")
provision_user_row(user_id=131, login="existingone", role="contributor")
sign_in_as(
client, user_id=130, gitea_login="adminD",
display_name="Admin D", role="admin",
)
# provision_user_row sets email to <login>@test, so:
r = client.post(
"/api/admin/users",
json={
"email": "existingone@test",
"role": "contributor",
},
)
assert r.status_code == 409, r.text
def test_admin_create_user_invite_owner_grant_refused_for_non_owner(app_with_fake_gitea):
"""An admin (not owner) trying to invite a fresh user as `owner` is
refused 422 — §6.1's owner-zero is the only bootstrap path."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=140, login="adminNoOwner", role="admin")
sign_in_as(
client, user_id=140, gitea_login="adminNoOwner",
display_name="Admin", role="admin",
)
r = client.post(
"/api/admin/users",
json={
"email": "wouldbeowner@example.com",
"role": "owner",
},
)
assert r.status_code == 422, r.text
def test_admin_create_user_invite_owner_can_invite_as_owner(app_with_fake_gitea):
"""A sitting owner can invite a fresh user as `owner` — the §6.1
role-grant channel. Sanity check that the owner-grant path itself
works, paired with the refusal above."""
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=150, login="ownerzero", role="owner")
sign_in_as(
client, user_id=150, gitea_login="ownerzero",
display_name="Owner Zero", role="owner",
)
r = client.post(
"/api/admin/users",
json={
"email": "newowner@example.com",
"role": "owner",
},
)
assert r.status_code == 200, r.text
row = db.conn().execute(
"SELECT role FROM users WHERE email = ? COLLATE NOCASE",
("newowner@example.com",),
).fetchone()
assert row["role"] == "owner"
def test_admin_create_user_invite_refuses_malformed_role(app_with_fake_gitea):
"""The pydantic regex refuses any role outside the §6.1 set."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=160, login="adminR", role="admin")
sign_in_as(
client, user_id=160, gitea_login="adminR",
display_name="Admin R", role="admin",
)
r = client.post(
"/api/admin/users",
json={
"email": "ok@example.com",
"role": "superuser",
},
)
assert r.status_code == 422
def test_admin_create_user_invite_refuses_long_custom_message(app_with_fake_gitea):
"""Custom message over the 500-char ceiling is refused 422."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=170, login="adminM", role="admin")
sign_in_as(
client, user_id=170, gitea_login="adminM",
display_name="Admin M", role="admin",
)
r = client.post(
"/api/admin/users",
json={
"email": "ok@example.com",
"role": "contributor",
"custom_message": "x" * 501,
},
)
assert r.status_code == 422
# ---------------------------------------------------------------------------
# Claim flow
# ---------------------------------------------------------------------------
def test_claim_with_valid_token_signs_in_and_marks_claimed(app_with_fake_gitea):
"""End-to-end: admin creates → invitee posts the token to
/api/invites/claim → session lands + row marked claimed +
last_seen_at stamps on the user row (the pending-invite
discriminator clears)."""
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=200, login="adminC", role="admin")
sign_in_as(
client, user_id=200, gitea_login="adminC",
display_name="Admin C", role="admin",
)
_reset_outbound()
r = client.post(
"/api/admin/users",
json={
"email": "claimant@example.com",
"first_name": "Clai",
"last_name": "Mant",
"role": "contributor",
},
)
assert r.status_code == 200
invite_id = r.json()["invite_id"]
invited_user_id = r.json()["invited_user_id"]
env = _outbound_invite_envelopes("claimant@example.com")[0]
token = _extract_claim_token(env)
# The invitee's request is anonymous (they have no session
# yet). We clear the admin's session cookie to simulate this.
client.cookies.clear()
r = client.post(
"/api/invites/claim",
json={"token": token},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["user"]["id"] == invited_user_id
assert body["user"]["role"] == "contributor"
assert body["user"]["permission_state"] == "granted"
# The user has no passcode set yet → frontend should route to
# passcode-set per the roadmap.
assert body["needs_passcode"] is True
# Row marked claimed; last_seen_at populated.
invite = db.conn().execute(
"SELECT claimed_at, claimed_by_user_id FROM user_invite_tokens "
"WHERE id = ?",
(invite_id,),
).fetchone()
assert invite["claimed_at"] is not None
assert invite["claimed_by_user_id"] == invited_user_id
user_row = db.conn().execute(
"SELECT last_seen_at FROM users WHERE id = ?",
(invited_user_id,),
).fetchone()
assert user_row["last_seen_at"] is not None
def test_claim_with_expired_token_returns_410(app_with_fake_gitea):
"""A token whose `expires_at` has passed surfaces as HTTP 410."""
from fastapi.testclient import TestClient
from app import db, invites
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=210, login="adminE", role="admin")
sign_in_as(
client, user_id=210, gitea_login="adminE",
display_name="Admin E", role="admin",
)
_reset_outbound()
# Create the invite, then back-date the expires_at to the past.
r = client.post(
"/api/admin/users",
json={
"email": "expired@example.com",
"role": "contributor",
},
)
assert r.status_code == 200
invite_id = r.json()["invite_id"]
db.conn().execute(
"UPDATE user_invite_tokens SET expires_at = datetime('now', '-1 day') "
"WHERE id = ?",
(invite_id,),
)
env = _outbound_invite_envelopes("expired@example.com")[0]
token = _extract_claim_token(env)
client.cookies.clear()
r = client.post("/api/invites/claim", json={"token": token})
assert r.status_code == 410, r.text
assert "expired" in r.json()["detail"].lower()
def test_claim_with_already_claimed_token_returns_410(app_with_fake_gitea):
"""Re-claiming an already-consumed token surfaces as HTTP 410."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=220, login="adminA", role="admin")
sign_in_as(
client, user_id=220, gitea_login="adminA",
display_name="Admin A", role="admin",
)
_reset_outbound()
r = client.post(
"/api/admin/users",
json={
"email": "twice@example.com",
"role": "contributor",
},
)
assert r.status_code == 200
env = _outbound_invite_envelopes("twice@example.com")[0]
token = _extract_claim_token(env)
client.cookies.clear()
# First claim succeeds.
r = client.post("/api/invites/claim", json={"token": token})
assert r.status_code == 200
# Second claim, with the same token, refuses with 410.
client.cookies.clear()
r = client.post("/api/invites/claim", json={"token": token})
assert r.status_code == 410, r.text
assert "already" in r.json()["detail"].lower()
def test_claim_with_unknown_token_returns_400(app_with_fake_gitea):
"""A token that doesn't match any active invite is HTTP 400."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# No invite ever created; the token is whatever the attacker
# types in. The endpoint should refuse without disclosing
# whether the token "looked" right.
r = client.post(
"/api/invites/claim",
json={"token": "totally-made-up-token-string-that-is-not-real"},
)
assert r.status_code == 400, r.text
# ---------------------------------------------------------------------------
# Pending-invite admin listing
# ---------------------------------------------------------------------------
def test_pending_invites_listing_shows_active_invites_only(app_with_fake_gitea):
"""The `GET /api/admin/users/invites` listing filters to active
invites — claimed and expired rows do not surface here (the admin
user-listing carries the per-row pending-invite badge for the
living rows; once claimed, the badge clears)."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=300, login="adminL", role="admin")
sign_in_as(
client, user_id=300, gitea_login="adminL",
display_name="Admin L", role="admin",
)
_reset_outbound()
# Create three invites: one stays pending, one we'll claim, one
# we'll back-date to expired.
for email in ("alive@ex.co", "claimed@ex.co", "expired@ex.co"):
r = client.post(
"/api/admin/users",
json={"email": email, "role": "contributor"},
)
assert r.status_code == 200
# Claim the middle one.
env = _outbound_invite_envelopes("claimed@ex.co")[0]
token_claim = _extract_claim_token(env)
# Expire the third one.
from app import db
db.conn().execute(
"UPDATE user_invite_tokens SET expires_at = datetime('now', '-1 day') "
"WHERE email = 'expired@ex.co'"
)
# The admin's session is still on the cookie. Claim works
# anonymously; we clear and restore.
admin_cookie = client.cookies.get("rfc_session")
client.cookies.clear()
r = client.post("/api/invites/claim", json={"token": token_claim})
assert r.status_code == 200
client.cookies.set("rfc_session", admin_cookie)
r = client.get("/api/admin/users/invites")
assert r.status_code == 200, r.text
items = r.json()["items"]
emails = sorted(i["email"] for i in items)
assert emails == ["alive@ex.co"]
def test_pending_invite_badge_clears_after_claim(app_with_fake_gitea):
"""The `/api/admin/users` listing surfaces `pending_invite` while
the invite is unclaimed; after the invitee claims, the row's
pending_invite is null."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=310, login="adminB", role="admin")
sign_in_as(
client, user_id=310, gitea_login="adminB",
display_name="Admin B", role="admin",
)
_reset_outbound()
r = client.post(
"/api/admin/users",
json={"email": "badgey@ex.co", "role": "contributor"},
)
assert r.status_code == 200
invited_id = r.json()["invited_user_id"]
# Before claim — pending_invite is populated.
r = client.get("/api/admin/users")
assert r.status_code == 200
row = next(u for u in r.json()["items"] if u["id"] == invited_id)
assert row["pending_invite"] is not None
assert row["pending_invite"]["invite_id"] > 0
# Claim.
env = _outbound_invite_envelopes("badgey@ex.co")[0]
token = _extract_claim_token(env)
admin_cookie = client.cookies.get("rfc_session")
client.cookies.clear()
r = client.post("/api/invites/claim", json={"token": token})
assert r.status_code == 200
client.cookies.set("rfc_session", admin_cookie)
# After claim — pending_invite is null.
r = client.get("/api/admin/users")
assert r.status_code == 200
row = next(u for u in r.json()["items"] if u["id"] == invited_id)
assert row["pending_invite"] is None
def test_pending_invites_listing_admin_only(app_with_fake_gitea):
"""The listing requires admin/owner; contributor gets 403."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=320, login="contribL", role="contributor")
sign_in_as(
client, user_id=320, gitea_login="contribL",
display_name="Contrib L", role="contributor",
)
r = client.get("/api/admin/users/invites")
assert r.status_code == 403