"""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, 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. --- 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