0c972c8af5
Retire the per-RFC-repo model. RFCs now live in their meta-repo entry (rfcs/<slug>.md) for their whole life; graduation is an in-place super-draft → active state flip that keeps the body in the entry — no repo creation, no body-strip, no five-step transaction, no rollback. SPEC: §1 topology rewritten (one meta/content repository, no per-RFC repos) with a deployer-facing "single content repository" framing; §2 (repo: always-null), §3 (active is in-place), §4, §9.8 (handoff frictions dissolve), and §13 fully rewritten (two-field dialog, "The flip", §13.6 RFC-0001 fold-back record). Code: graduation collapses to open+merge one frontmatter PR; branch/PR/ chat dispatch re-keyed on meta-residency (repo IS NULL) so active RFCs edit on the meta repo exactly as super-drafts do; the two "RFC has no repo" 409 guards removed; promote-to-branch slug-embeds an active RFC's auto-branch (edit-<slug>-<hex>) for shared-repo cache attribution; refresh_meta_branches + hygiene branch-resolution include meta-resident actives; the §9.8 read-only guard + pre_graduation_history scoped to legacy per-repo only; dead bot primitives + GraduateDialog repo field + blocking-PR popover removed. The repo: frontmatter field and the /blocking-prs endpoint are retained (schema stability / informational). Tests: graduation suite rewritten to the flip model; e2e + hygiene updated. Full backend suite 375 passed; frontend builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
261 lines
11 KiB
Python
261 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 (in-place flip, §13). ---
|
|
r = client.post(
|
|
"/api/rfcs/ohm/graduate?_sync=1",
|
|
json={"rfc_id": "RFC-0001", "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"
|
|
# Meta-only topology (§1): no per-RFC repo — the active RFC lives
|
|
# in its meta entry, `repo` stays null.
|
|
assert d["repo"] is None
|
|
|
|
# --- 8. Alice opens a PR on the now-active RFC (meta 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 (meta repo) + cached row flipped.
|
|
assert active_branch not in fake.branches[("wiggleverse", "meta")]
|
|
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.)
|
|
# v0.18.0 Slice 5: the response now includes `correlated_id`
|
|
# (the outbound_emails row id that matched the bounce's
|
|
# `message_id`, if one was supplied). The body didn't pass a
|
|
# message_id, so correlated_id is None.
|
|
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, "correlated_id": None}
|
|
|
|
|
|
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
|