Release 0.16.0: owner-only invite for per-RFC contribution + discussion

Lands roadmap item #12: the RFC's owner can invite specific users by
email to one of two per-RFC roles — contributor (open PRs and join
discussion) or discussant (join discussion only). Non-invited users
keep the v0.6.0 anonymous-read contract: they can read but cannot
write/discuss that RFC. The platform-level grant (v0.8.0 / item #6)
is unchanged; this release adds a per-RFC membership layer beneath
it.

Migration 018_rfc_invitations.sql adds rfc_invitations (the
lifecycle row with the email token and 30-day expiry) and
rfc_collaborators (the accepted-invitation substrate the write
gate consults). FK-cascaded against cached_rfcs and users per §5.

New endpoints (in backend/app/api_invitations.py):
  POST   /api/rfcs/{slug}/invitations              (owner)
  GET    /api/rfcs/{slug}/invitations              (owner)
  POST   /api/rfcs/{slug}/invitations/{id}/revoke  (owner)
  GET    /api/invitations/accept?token=…           (signed-in)
  POST   /api/invitations/accept                   (signed-in)

The discussion / branch / open-pr write surfaces compose new
auth.can_discuss_rfc and auth.can_contribute_to_rfc predicates
after the existing require_contributor check. A super-draft with
no frontmatter owners yet falls through to the platform-granted
contract — the gate engages only once an owner exists.

The email path reuses the existing SMTP plumbing (EmailConfig.from_env)
the v0.7.0 OTC and v0.5.0 notification mailers share — transactional
envelope, no preferences honored, no unsubscribe footer.

The §17 admin user-management surface (item #7, v0.9.0) is extended
additively: GET /api/admin/users carries a new per-user
rfc_invitations array naming each accepted per-RFC collaboration
with inviter / RFC / role / timestamp. The platform-grant decision
keeps its context without restructuring the existing shape.

Frontend additions: InvitationsModal.jsx (owner-only RFC view
header strip affordance), AcceptInvitation.jsx (the
/invitations/accept landing page), five api.js helpers.

237 backend tests pass (18 new in test_rfc_invitations_vertical.py,
three older tests updated to opt into the per-RFC contributor
contract via the new grant_rfc_collaborator test seam). Frontend
build clean.

No new env vars. No new overlay keys. Migration auto-applied on
backend start by the existing db.run_migrations() sweep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-28 04:53:07 -07:00
parent b3f1b15f65
commit a51beecbc9
21 changed files with 2318 additions and 2 deletions
+6
View File
@@ -22,6 +22,7 @@ import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
grant_rfc_collaborator,
provision_user_row,
sign_in_as,
tmp_env,
@@ -131,6 +132,11 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
assert d["repo"] == "wiggleverse/rfc-0001-ohm"
# --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. ---
# v0.16.0 (item #12): ben is the RFC owner now; alice needs a
# per-RFC contributor invitation to cut a branch. In the
# production flow, ben would invite her via /invitations and
# she'd accept; we shortcut to the same end-state.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor", email="alice@test")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
@@ -34,6 +34,7 @@ import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
grant_rfc_collaborator,
provision_user_row,
sign_in_as,
tmp_env,
@@ -248,6 +249,9 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
# v0.16.0 (item #12): ben is the RFC owner; alice needs a per-RFC
# contributor invitation to cut an edit branch on the super-draft.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
@@ -497,6 +501,8 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
# v0.16.0 (item #12): alice needs per-RFC contributor access.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
# Alice cuts an edit branch and starts chatting on it.
sign_in_as(client, user_id=2, gitea_login="alice",
+11
View File
@@ -21,6 +21,7 @@ import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
grant_rfc_collaborator,
provision_user_row,
sign_in_as,
tmp_env,
@@ -140,6 +141,9 @@ def test_get_pr_returns_three_column_payload(app_with_fake_gitea):
provision_user_row(user_id=3, login="bob", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# Bob is the non-arbiter contributor — alice is seeded as an RFC owner.
# v0.16.0 (item #12): bob needs an accepted per-RFC contributor
# invitation to cut branches and open PRs on alice's RFC.
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
branch, _ = _cut_branch_and_accept_change(
client, fake, slug="ohm",
@@ -292,6 +296,9 @@ def test_merge_by_arbiter_advances_main_and_marks_pr_merged(app_with_fake_gitea)
provision_user_row(user_id=1, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# Bob is neither owner nor arbiter — the non-merge baseline.
# v0.16.0 (item #12): bob still needs an accepted contributor
# invitation to cut the branch + open the PR.
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
branch, _ = _cut_branch_and_accept_change(
client, fake, slug="ohm",
@@ -364,6 +371,10 @@ def test_resolution_branch_replays_clean_and_supersedes_on_merge(app_with_fake_g
provision_user_row(user_id=3, login="bob", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# v0.16.0 (item #12): bob (a non-owner contributor) needs an
# accepted per-RFC invitation to cut a branch on alice's RFC.
# Alice is the seeded RFC owner so she doesn't need one.
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
# Alice cuts a branch and accepts a change on it.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
+22
View File
@@ -395,6 +395,28 @@ def provision_user_row(*, user_id: int, login: str, role: str) -> None:
)
def grant_rfc_collaborator(*, user_id: int, rfc_slug: str, role_in_rfc: str = "contributor") -> None:
"""v0.16.0 / item #12 test seam: directly insert an accepted-
invitation collaborator row so a non-owner contributor can pass
the per-RFC write gate without going through the email round-trip.
Equivalent in effect to the invitationaccept dance the production
code drives; lets v0.5.0/v0.6.0/v0.8.0 era tests preserve their
"alice owns OHM, bob contributes" shape without rewriting the
setup. The invitation_id is left NULL collaborators minted via
a direct admin gesture (a §19.2 candidate) carry the same shape.
"""
from app import db
db.conn().execute(
"""
INSERT OR REPLACE INTO rfc_collaborators
(rfc_slug, user_id, role_in_rfc, invitation_id)
VALUES (?, ?, ?, NULL)
""",
(rfc_slug, user_id, role_in_rfc),
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@@ -0,0 +1,658 @@
"""End-to-end integration tests for v0.16.0's owner-only invite for
per-RFC PR or PR-less discussion (roadmap item #12, §6 / §10).
The release lands a per-RFC membership layer:
* `rfc_invitations` issued by the RFC's owner, addressed to an
email, granting one of two roles ('contributor' or 'discussant').
* `rfc_collaborators` the accepted-invitation substrate; the
table the per-RFC write gate consults.
The tests prove:
* Only the RFC's owner (or a platform admin/owner) can invite —
a platform-granted but non-owner user gets 403.
* Creating an invitation lands a row, mints a token, and queues
an envelope on the SMTP buffer.
* Re-inviting the same (email, role) on the same RFC returns 409.
* The accept endpoint requires the accepting user's email to match
the invitee_email (case-insensitive).
* Acceptance lands a rfc_collaborators row and flips the
invitation to 'accepted'.
* Re-accepting the same invitation is idempotent (200, changed=false).
* An expired invitation refuses 409 even if the row's column status
is still 'pending'.
* A revoked invitation refuses 409.
* The owner's listing carries pending + accepted in one response.
* The per-RFC discussion-write gate refuses a non-invited
platform-granted user 403 (was previously 200 before v0.16.0).
* The same gate admits a user who holds an accepted 'discussant'
invitation.
* The same gate admits a user who holds an accepted 'contributor'
invitation (contributor strictly includes discussion).
* The platform admin/owner is admitted regardless of per-RFC
membership (the platform-level capability path).
* The /api/admin/users listing carries `rfc_invitations` per-user
after an acceptance the §17 admin surface hook.
"""
from __future__ import annotations
# Reuse fixtures and helpers from the propose / RFC-view harnesses.
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_rfc_view_vertical import seed_active_rfc, SEED_BODY
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _invitation_envelopes(to_address: str | None = None) -> list[dict]:
"""Pluck v0.16.0 invitation envelopes out of the shared _SENT buffer.
Same access pattern as the OTC tests use for `kind='otc'`."""
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "rfc_invitation":
continue
if to_address is not None and env["to"] != to_address:
continue
out.append(env)
return out
# ---------------------------------------------------------------------------
# Create / list / revoke (owner-side)
# ---------------------------------------------------------------------------
def test_owner_can_invite_creates_row_and_sends_email(app_with_fake_gitea):
"""The end-to-end create gesture: RFC owner posts an invitation,
a row lands, the token comes back in the response, and an
`rfc_invitation`-kind envelope hits the SMTP buffer."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# The frontmatter owner of the seeded RFC is "alice" (per
# seed_active_rfc's default), so we sign in as that user.
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor",
)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newperson@example.com", "role_in_rfc": "contributor"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["rfc_slug"] == "ohm"
assert body["invitee_email"] == "newperson@example.com"
assert body["role_in_rfc"] == "contributor"
assert body["status"] == "pending"
assert body["token"] and len(body["token"]) > 16
# Row landed.
row = db.conn().execute(
"SELECT * FROM rfc_invitations WHERE id = ?", (body["id"],),
).fetchone()
assert row["rfc_slug"] == "ohm"
assert row["invitee_email"] == "newperson@example.com"
assert row["inviter_user_id"] == 1
assert row["status"] == "pending"
# Email envelope went out.
envs = _invitation_envelopes("newperson@example.com")
assert len(envs) == 1
assert "OHM" in envs[0]["subject"]
assert body["token"] in envs[0]["body"]
def test_non_owner_cannot_invite(app_with_fake_gitea):
"""A platform-granted user who isn't in the RFC's frontmatter
owners list cannot invite 403. Distinct from the
require_contributor gate (which would be 401 for anonymous)."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
# alice is the RFC owner per the seed; bob is a regular
# platform-granted contributor with no per-RFC role.
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="bob", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=2, gitea_login="bob",
display_name="Bob", role="contributor",
)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "ignored@example.com", "role_in_rfc": "discussant"},
)
assert r.status_code == 403
def test_platform_admin_can_invite_to_any_rfc(app_with_fake_gitea):
"""Per §6.1 the platform admin/owner role carries the maximal
per-RFC capability, so admins can invite on any RFC even if
they're not in its owners list."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=99, login="adminzero", role="admin")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=99, gitea_login="adminzero",
display_name="Admin Zero", role="admin",
)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "another@example.com", "role_in_rfc": "discussant"},
)
assert r.status_code == 200, r.text
def test_anonymous_cannot_invite(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "x@example.com", "role_in_rfc": "discussant"},
)
assert r.status_code == 401
def test_re_invite_same_email_and_role_returns_409(app_with_fake_gitea):
"""Refuse a duplicate pending invitation for the same (email, role)
on the same RFC. A different role on the same email is allowed
(the owner may want to upgrade discussant contributor)."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor",
)
r1 = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "dup@example.com", "role_in_rfc": "discussant"},
)
assert r1.status_code == 200
r2 = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "dup@example.com", "role_in_rfc": "discussant"},
)
assert r2.status_code == 409
# Same email, different role is allowed.
r3 = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "dup@example.com", "role_in_rfc": "contributor"},
)
assert r3.status_code == 200
def test_owner_can_list_invitations(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor",
)
client.post("/api/rfcs/ohm/invitations",
json={"invitee_email": "a@example.com", "role_in_rfc": "discussant"})
client.post("/api/rfcs/ohm/invitations",
json={"invitee_email": "b@example.com", "role_in_rfc": "contributor"})
r = client.get("/api/rfcs/ohm/invitations")
assert r.status_code == 200, r.text
items = r.json()["items"]
emails = sorted(i["invitee_email"] for i in items)
assert emails == ["a@example.com", "b@example.com"]
assert all(i["status"] == "pending" for i in items)
# The inviter is named.
assert all(i["inviter_login"] == "alice" for i in items)
def test_revoke_pending_invitation_works_already_accepted_refuses(app_with_fake_gitea):
"""Revoke flips a pending invitation to 'revoked'. An already-
accepted invitation refuses 409 accepted membership is removed
via a different (future) surface; the v0.16.0 revoke only lifts
the pending link."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor",
)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "revokee@example.com", "role_in_rfc": "discussant"},
)
invitation_id = r.json()["id"]
r = client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
assert r.status_code == 200
assert r.json()["status"] == "revoked"
# Re-revoke refuses 409.
r2 = client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
assert r2.status_code == 409
row = db.conn().execute(
"SELECT status FROM rfc_invitations WHERE id = ?", (invitation_id,),
).fetchone()
assert row["status"] == "revoked"
# ---------------------------------------------------------------------------
# Accept (invitee-side)
# ---------------------------------------------------------------------------
def test_accept_invitation_lands_collaborator_row(app_with_fake_gitea):
"""The end-to-end accept gesture: the invitee signs in, posts the
token, and an rfc_collaborators row lands at the issued role."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
# provision_user_row sets the email to "<login>@test", so the
# invitee row we'll create needs the same email shape.
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# alice (owner) invites newbie@test.
sign_in_as(
client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor",
)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
)
assert r.status_code == 200, r.text
token = r.json()["token"]
# Switch to newbie, accept.
sign_in_as(
client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test",
)
r = client.post("/api/invitations/accept", json={"token": token})
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["changed"] is True
assert body["rfc_slug"] == "ohm"
assert body["role_in_rfc"] == "contributor"
# Collaborator row landed; invitation flipped.
collab = db.conn().execute(
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = 'ohm' AND user_id = 2",
).fetchone()
assert collab is not None
assert collab["role_in_rfc"] == "contributor"
inv = db.conn().execute(
"SELECT status, accepted_by_user_id FROM rfc_invitations WHERE token = ?",
(token,),
).fetchone()
assert inv["status"] == "accepted"
assert inv["accepted_by_user_id"] == 2
def test_accept_refuses_when_email_does_not_match(app_with_fake_gitea):
"""The accepting user's email must match the invitation's
invitee_email (case-insensitive)."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="mallory", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "intended@example.com", "role_in_rfc": "discussant"},
)
token = r.json()["token"]
# mallory's email is "mallory@test", not "intended@example.com".
sign_in_as(client, user_id=2, gitea_login="mallory",
display_name="Mallory", role="contributor",
email="mallory@test")
r = client.post("/api/invitations/accept", json={"token": token})
assert r.status_code == 403
def test_accept_refuses_revoked_invitation(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
)
invitation_id = r.json()["id"]
token = r.json()["token"]
client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
r = client.post("/api/invitations/accept", json={"token": token})
assert r.status_code == 409
def test_accept_refuses_expired_invitation(app_with_fake_gitea):
"""An invitation past its `expires_at` is refused 409 even if
the row's column status is still 'pending'. We backdate the
expires_at directly to model the elapsed-window state."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
)
token = r.json()["token"]
invitation_id = r.json()["id"]
# Backdate.
db.conn().execute(
"UPDATE rfc_invitations SET expires_at = datetime('now', '-1 day') WHERE id = ?",
(invitation_id,),
)
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
r = client.post("/api/invitations/accept", json={"token": token})
assert r.status_code == 409
def test_accept_is_idempotent_on_re_accept(app_with_fake_gitea):
"""Re-accepting the same already-accepted invitation reads as a
200 no-op with `changed=false`. The collaborator row is unchanged."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
)
token = r.json()["token"]
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
r1 = client.post("/api/invitations/accept", json={"token": token})
assert r1.status_code == 200
assert r1.json()["changed"] is True
r2 = client.post("/api/invitations/accept", json={"token": token})
assert r2.status_code == 200
assert r2.json()["changed"] is False
# Still exactly one collaborator row.
rows = db.conn().execute(
"SELECT COUNT(*) AS n FROM rfc_collaborators WHERE rfc_slug = 'ohm' AND user_id = 2"
).fetchone()
assert rows["n"] == 1
# ---------------------------------------------------------------------------
# Discussion-write gate enforcement
# ---------------------------------------------------------------------------
def test_non_invited_user_cannot_post_to_discussion(app_with_fake_gitea):
"""v0.16.0 narrows the discussion-write gate: a platform-granted
user with no per-RFC role gets 403 when posting to the
discussion. (v0.6.0 left the gate at require_contributor only;
item #12 layers can_discuss_rfc on top.)"""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="bob", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# bob is platform-granted but not in OHM's owners list and has
# no invitation. The thread-create surface refuses 403.
sign_in_as(client, user_id=2, gitea_login="bob",
display_name="Bob", role="contributor")
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Question", "message": "Should I be allowed?"},
)
assert r.status_code == 403
def test_invited_discussant_can_post_to_discussion(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# alice invites newbie as a discussant.
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
)
token = r.json()["token"]
# newbie accepts.
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
client.post("/api/invitations/accept", json={"token": token})
# newbie can now post to the discussion.
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Question", "message": "Now I can speak."},
)
assert r.status_code == 200, r.text
def test_contributor_role_includes_discussion(app_with_fake_gitea):
"""A 'contributor' per-RFC role strictly includes discussion
permission accepting a contributor invitation admits the user
to the discussion endpoint too."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
)
token = r.json()["token"]
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
client.post("/api/invitations/accept", json={"token": token})
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Q", "message": "Hello."},
)
assert r.status_code == 200
def test_platform_admin_can_post_to_discussion_without_invitation(app_with_fake_gitea):
"""Per §6.1 / item #12's permission shape: platform admins/owners
can write to any RFC's discussion regardless of per-RFC
membership."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=99, login="adminzero", role="admin")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=99, gitea_login="adminzero",
display_name="Admin Zero", role="admin")
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Admin chime", "message": "Drive-by from admin."},
)
assert r.status_code == 200
def test_rfc_owner_can_post_to_discussion(app_with_fake_gitea):
"""The frontmatter RFC owner is admitted by virtue of being on
the owners list they don't need to invite themselves."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Owner thought", "message": "Kicking off the conversation."},
)
assert r.status_code == 200
# ---------------------------------------------------------------------------
# Admin-page hook (additive on /api/admin/users)
# ---------------------------------------------------------------------------
def test_admin_users_listing_surfaces_per_rfc_invitations(app_with_fake_gitea):
"""v0.16.0 hook into the v0.9.0 admin user-management surface:
each user row carries an `rfc_invitations` array listing the
per-RFC roles they hold. Empty array for users without any."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
provision_user_row(user_id=99, login="adminzero", role="admin")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# alice invites newbie; newbie accepts.
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
)
token = r.json()["token"]
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
client.post("/api/invitations/accept", json={"token": token})
# Admin lists.
sign_in_as(client, user_id=99, gitea_login="adminzero",
display_name="Admin Zero", role="admin")
r = client.get("/api/admin/users")
assert r.status_code == 200
items = r.json()["items"]
newbie_row = next(i for i in items if i["gitea_login"] == "newbie")
assert isinstance(newbie_row["rfc_invitations"], list)
assert len(newbie_row["rfc_invitations"]) == 1
invite = newbie_row["rfc_invitations"][0]
assert invite["rfc_slug"] == "ohm"
assert invite["role_in_rfc"] == "contributor"
assert invite["inviter_login"] == "alice"
# Users with no invitations carry an empty array, not null.
alice_row = next(i for i in items if i["gitea_login"] == "alice")
assert alice_row["rfc_invitations"] == []