Files
rfc-app/backend/tests/test_e2e_smoke.py
T
Ben Stull a51beecbc9 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>
2026-05-28 04:53:07 -07:00

256 lines
11 KiB
Python

"""End-to-end smoke test for the Slice 8 hardening pass.
Walks the full user lifecycle against FakeGitea: propose → owner
merges → super-draft view → start edit branch → AI proposes (seeded
directly) → accept → open body-edit PR → owner merges → graduate →
active-RFC PR open → owner merges → §12 hygiene sweep deletes the
post-merge branch. The cases are long, and they catch the integration
seams a per-slice test would miss — that's the point per the §19.1
brief.
Plus the bounce-webhook signing-seam test: when
`WEBHOOK_EMAIL_BOUNCE_SECRET` is set, an unsigned POST is refused.
"""
from __future__ import annotations
import asyncio
import json as _json
from datetime import datetime, timedelta, timezone
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,
)
# ---------------------------------------------------------------------------
# The lifecycle walk
# ---------------------------------------------------------------------------
def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
"""Propose → merge → super-draft view → edit branch → accept change
→ body-edit PR → merge → graduate → active-RFC PR → merge →
§12 hygiene sweep cleans the post-merge branch.
The §15 notification path runs through `bot._log`'s fan_out at
every step; we assert at the end that the inbox has rows."""
from fastapi.testclient import TestClient
from app import cache as cache_mod, db, hygiene
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=2, login="alice", role="contributor")
# --- 1. Alice proposes a new RFC. ---
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor", email="alice@test")
r = client.post("/api/rfcs/propose", json={
"title": "Open Human Model",
"slug": "ohm",
"pitch": "A shared definition of what we mean by *human*.",
"tags": ["identity"],
})
assert r.status_code == 200, r.text
proposal_pr = r.json()["pr_number"]
# --- 2. Ben (owner) merges the proposal → super-draft exists. ---
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
r = client.post(f"/api/proposals/{proposal_pr}/merge")
assert r.status_code == 200, r.text
d = client.get("/api/rfcs/ohm").json()
assert d["state"] == "super-draft"
# --- 3. Ben claims ownership so he can graduate later. ---
r = client.post("/api/rfcs/ohm/claim")
assert r.status_code == 200, r.text
claim_pr = r.json()["pr_number"]
# Claim PR also auto-merges per §13.1's owner/admin path.
r = client.post(f"/api/rfcs/ohm/prs/{claim_pr}/merge")
assert r.status_code == 200, r.text
# --- 4. Ben starts an edit branch on the super-draft. ---
r = client.post("/api/rfcs/ohm/start-edit-branch", json={})
assert r.status_code == 200, r.text
edit_branch = r.json()["branch_name"]
assert edit_branch.startswith("edit-ohm-")
# --- 5. Materialize an AI-style change directly + accept. ---
view = client.get(f"/api/rfcs/ohm/branches/{edit_branch}").json()
thread_id = view["main_thread_id"]
cur = db.conn().execute(
"""
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
original, proposed, reason)
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, 'tighten')
""",
(
edit_branch, thread_id,
"A shared definition of what we mean by *human*.",
"A shared, OHM-compatible definition of what we mean by *human*.",
),
)
change_id = cur.lastrowid
r = client.post(
f"/api/rfcs/ohm/branches/{edit_branch}/changes/{change_id}/accept",
json={
"proposed": "A shared, OHM-compatible definition of what we mean by *human*.",
"was_edited_before_accept": False,
},
)
assert r.status_code == 200, r.text
# --- 6. Open the body-edit PR + merge it. ---
r = client.post(
f"/api/rfcs/ohm/branches/{edit_branch}/open-pr",
json={"title": "OHM body edit", "description": "Add OHM-compatibility clause."},
)
assert r.status_code == 200, r.text
body_pr = r.json()["pr_number"]
r = client.post(f"/api/rfcs/ohm/prs/{body_pr}/merge")
assert r.status_code == 200, r.text
# --- 7. Graduate the super-draft. ---
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
)
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is True
d = client.get("/api/rfcs/ohm").json()
assert d["state"] == "active"
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={})
assert r.status_code == 200, r.text
active_branch = r.json()["branch_name"]
# Materialize and accept a change so the branch has commits ahead.
view = client.get(f"/api/rfcs/ohm/branches/{active_branch}").json()
active_thread = view["main_thread_id"]
cur = db.conn().execute(
"""
INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state,
original, proposed, reason)
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, 'expand')
""",
(
active_branch, active_thread,
"OHM-compatible definition",
"OHM-compatible, traceable definition",
),
)
change_id = cur.lastrowid
r = client.post(
f"/api/rfcs/ohm/branches/{active_branch}/changes/{change_id}/accept",
json={"proposed": "OHM-compatible, traceable definition",
"was_edited_before_accept": False},
)
assert r.status_code == 200, r.text
r = client.post(
f"/api/rfcs/ohm/branches/{active_branch}/open-pr",
json={"title": "Traceability clause", "description": "Add a traceability term."},
)
assert r.status_code == 200, r.text
active_pr = r.json()["pr_number"]
# --- 9. Ben merges the active-RFC PR. ---
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
r = client.post(f"/api/rfcs/ohm/prs/{active_pr}/merge")
assert r.status_code == 200, r.text
# --- 10. Notifications fanned out — Ben's inbox carries rows. ---
r = client.get("/api/notifications")
assert r.status_code == 200, r.text
inbox = r.json()
assert "items" in inbox
# The merge of Alice's PR should at minimum have produced a
# structural beat to watchers (Ben auto-watched on his earlier
# gestures on the slug).
kinds = {item["event_kind"] for item in inbox["items"]}
assert kinds, f"expected non-empty inbox kinds, got: {inbox}"
# --- 11. Backdate the merge so the §12 hygiene sweep deletes
# the branch, then run the sweep. ---
long_ago = (datetime.now(timezone.utc) - timedelta(days=120)).strftime("%Y-%m-%d %H:%M:%S")
db.conn().execute(
"UPDATE cached_prs SET merged_at = ? WHERE pr_number = ?",
(long_ago, active_pr),
)
counters = asyncio.new_event_loop().run_until_complete(
hygiene.run_tick(config=app.state.config, bot=app.state.bot)
)
assert counters["deleted_post_merge"] >= 1, counters
# The branch is gone from FakeGitea + cached row flipped.
assert active_branch not in fake.branches[("wiggleverse", "rfc-0001-ohm")]
cached = db.conn().execute(
"SELECT state FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?",
(active_branch,),
).fetchone()
assert cached["state"] == "deleted"
# ---------------------------------------------------------------------------
# Bounce-webhook signing seam (§19.2 → settled)
# ---------------------------------------------------------------------------
def test_bounce_webhook_refuses_unsigned_when_secret_configured(app_with_fake_gitea, monkeypatch):
"""When `WEBHOOK_EMAIL_BOUNCE_SECRET` is set, the webhook requires
the same value in the `X-Webhook-Secret` header. An unsigned POST
returns 401."""
from fastapi.testclient import TestClient
monkeypatch.setenv("WEBHOOK_EMAIL_BOUNCE_SECRET", "shhh")
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.post(
"/api/webhooks/email-bounce",
json={"email": "stranger@example.com", "kind": "hard"},
)
assert r.status_code == 401, r.text
# With the right header, the call passes the guard. (No matching
# user exists, so we get {matched: False} — that's the v1 contract.)
r = client.post(
"/api/webhooks/email-bounce",
json={"email": "stranger@example.com", "kind": "hard"},
headers={"X-Webhook-Secret": "shhh"},
)
assert r.status_code == 200, r.text
assert r.json() == {"ok": True, "matched": False}
def test_bounce_webhook_open_when_secret_unset(app_with_fake_gitea):
"""The v1 contract: when no `WEBHOOK_EMAIL_BOUNCE_SECRET` is set,
the webhook stays unauthenticated for dev. The SMTP provider's
callback URL is the only contract."""
from fastapi.testclient import TestClient
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.post(
"/api/webhooks/email-bounce",
json={"email": "nobody@example.com", "kind": "complaint"},
)
assert r.status_code == 200, r.text