49ba06e0c2
Optional number (§13.2/§13.3): GraduateBody.rfc_id is now optional.
A blank/absent id graduates to `active` with `id: null`, leaving the
slug as the canonical identifier (§2.3). /graduate/check treats a blank
id as valid (ok:true); /graduate only validates the RFC-NNNN regex +
collision check when a number is supplied. The Graduate dialog allows an
empty number and renders number-less entries by slug (no RFC-undefined).
Retire (§3, §3.1, §13.7): new `retired` soft-delete state. An RFC's own
owners (frontmatter) and site `owner`-role holders — not app admins —
retire via POST /api/rfcs/<slug>/retire, an auto-merged meta-repo flip
PR (graduation machinery reused). Retired entries leave every browsing
surface: catalog, get_rfc (404 except site owner), discussion/branch
reads. Un-retire is site-owner-only (POST .../unretire), discoverable
via the owner-gated GET /api/admin/retired-rfcs ("Retired" admin tab).
Migration 025 widens the cached_rfcs.state CHECK to include 'retired'
(table rebuild, all columns preserved).
Tests: graduation no-number cases (active+null id by slug; check accepts
blank) added to test_graduation_vertical.py; new test_retire_vertical.py
covers perms (owner/site-owner allowed, admin/contributor 403),
catalog/read exclusion, and a graduate→retire→unretire round-trip. Full
backend suite 386 passing; frontend builds clean. SPEC §3/§3.1/§13
updated; CHANGELOG + VERSION → 0.33.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
258 lines
11 KiB
Python
258 lines
11 KiB
Python
"""End-to-end integration tests for the §13.7 retire (soft-delete) flow.
|
|
|
|
Retire is an in-place frontmatter flip on the meta entry (state →
|
|
`retired`) committed via an auto-merged PR — the same machinery as
|
|
graduation, reused. The distinguishing rules under test:
|
|
|
|
* Authority (§3.1): RFC owners (frontmatter) and site `owner`-role
|
|
holders may retire; app admins may NOT. Un-retire is site-owners-only.
|
|
* Visibility (§13.7): a retired entry drops out of the catalog
|
|
(`GET /api/rfcs`), and `GET /api/rfcs/<slug>` 404s for everyone except
|
|
a site owner (so the un-retire affordance has a surface).
|
|
* Reversibility: a site owner can un-retire, restoring the prior state
|
|
(and keeping the integer id intact); an RFC owner cannot.
|
|
|
|
These walk against the in-process FakeGitea from test_propose_vertical.py,
|
|
reusing the super-draft seed + sync graduation seam from the graduation
|
|
suite.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json as _json
|
|
|
|
from test_propose_vertical import ( # noqa: F401
|
|
FakeGitea,
|
|
app_with_fake_gitea,
|
|
grant_rfc_collaborator,
|
|
provision_user_row,
|
|
sign_in_as,
|
|
tmp_env,
|
|
)
|
|
from test_super_draft_vertical import seed_super_draft # noqa: F401
|
|
from test_graduation_vertical import PITCH, seed_owned_super_draft # noqa: F401
|
|
|
|
|
|
def _catalog_slugs(client) -> set[str]:
|
|
return {i["slug"] for i in client.get("/api/rfcs").json()["items"]}
|
|
|
|
|
|
def test_rfc_owner_can_retire_and_entry_leaves_every_surface(app_with_fake_gitea):
|
|
"""An RFC owner (frontmatter, role contributor) retires their own
|
|
super-draft. The entry flips to `retired`, drops out of the catalog,
|
|
and `GET /api/rfcs/<slug>` 404s for them (they are not a site owner)."""
|
|
from fastapi.testclient import TestClient
|
|
from app import db, entry as entry_mod
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="carol", role="contributor")
|
|
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
|
pitch=PITCH, owners=["carol"], arbiters=["carol"])
|
|
sign_in_as(client, user_id=2, gitea_login="carol",
|
|
display_name="Carol", role="contributor")
|
|
|
|
assert "ohm" in _catalog_slugs(client)
|
|
|
|
r = client.post("/api/rfcs/ohm/retire")
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["state"] == "retired"
|
|
|
|
# Meta entry on main: state retired, body + fields kept.
|
|
meta = entry_mod.parse(
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
|
)
|
|
assert meta.state == "retired"
|
|
assert "carol" in meta.owners
|
|
|
|
# Cache flipped; gone from the catalog.
|
|
cached = db.conn().execute(
|
|
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
|
|
).fetchone()
|
|
assert cached["state"] == "retired"
|
|
assert "ohm" not in _catalog_slugs(client)
|
|
|
|
# The RFC owner is NOT a site owner → 404 on the entry read.
|
|
assert client.get("/api/rfcs/ohm").status_code == 404
|
|
|
|
# Audit row records the prior state for un-retire.
|
|
row = db.conn().execute(
|
|
"SELECT details FROM actions WHERE rfc_slug='ohm' AND action_kind='retire' ORDER BY id DESC LIMIT 1"
|
|
).fetchone()
|
|
assert row is not None
|
|
assert _json.loads(row["details"])["prior_state"] == "super-draft"
|
|
|
|
|
|
def test_site_owner_sees_retired_entry_but_admin_and_others_404(app_with_fake_gitea):
|
|
"""`GET /api/rfcs/<slug>` for a retired entry: site owner gets 200
|
|
(so the un-retire UI has a surface); an admin, a non-owner contributor,
|
|
and an anonymous viewer all get 404."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
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="carol", role="contributor")
|
|
provision_user_row(user_id=3, login="dave", role="admin")
|
|
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
|
pitch=PITCH, owners=["carol"])
|
|
|
|
sign_in_as(client, user_id=2, gitea_login="carol",
|
|
display_name="Carol", role="contributor")
|
|
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
|
|
|
# Site owner: 200.
|
|
sign_in_as(client, user_id=1, gitea_login="ben",
|
|
display_name="Ben", role="owner")
|
|
r = client.get("/api/rfcs/ohm")
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["state"] == "retired"
|
|
|
|
# Admin (not site owner): 404.
|
|
sign_in_as(client, user_id=3, gitea_login="dave",
|
|
display_name="Dave", role="admin")
|
|
assert client.get("/api/rfcs/ohm").status_code == 404
|
|
|
|
# Non-owner contributor: 404.
|
|
provision_user_row(user_id=4, login="erin", role="contributor")
|
|
sign_in_as(client, user_id=4, gitea_login="erin",
|
|
display_name="Erin", role="contributor")
|
|
assert client.get("/api/rfcs/ohm").status_code == 404
|
|
|
|
|
|
def test_admin_cannot_retire(app_with_fake_gitea):
|
|
"""§3.1: retire authority excludes app admins. An admin who is not an
|
|
RFC owner gets 403 — the one lifecycle action where admin authority
|
|
does not apply."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=3, login="dave", role="admin")
|
|
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
|
pitch=PITCH, owners=["carol"])
|
|
sign_in_as(client, user_id=3, gitea_login="dave",
|
|
display_name="Dave", role="admin")
|
|
r = client.post("/api/rfcs/ohm/retire")
|
|
assert r.status_code == 403, r.text
|
|
|
|
|
|
def test_non_owner_contributor_cannot_retire(app_with_fake_gitea):
|
|
"""A signed-in contributor who is not an RFC owner gets 403."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=4, login="erin", role="contributor")
|
|
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
|
pitch=PITCH, owners=["carol"])
|
|
sign_in_as(client, user_id=4, gitea_login="erin",
|
|
display_name="Erin", role="contributor")
|
|
assert client.post("/api/rfcs/ohm/retire").status_code == 403
|
|
|
|
|
|
def test_site_owner_can_retire_active_and_unretire_restores_active_with_id(app_with_fake_gitea):
|
|
"""Round-trip on an active RFC: graduate (with a number) → retire →
|
|
un-retire. The integer id survives, and un-retire restores `active`."""
|
|
from fastapi.testclient import TestClient
|
|
from app import db, entry as entry_mod
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=1, login="ben", role="owner")
|
|
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
|
pitch=PITCH, owners=["ben"], arbiters=["ben"])
|
|
sign_in_as(client, user_id=1, gitea_login="ben",
|
|
display_name="Ben", role="owner", email="ben@test")
|
|
|
|
# Graduate with a number.
|
|
assert client.post("/api/rfcs/ohm/graduate?_sync=1",
|
|
json={"rfc_id": "RFC-0042", "owners": ["ben"]}).status_code == 200
|
|
|
|
# Retire (site owner).
|
|
assert client.post("/api/rfcs/ohm/retire").json()["state"] == "retired"
|
|
assert "ohm" not in _catalog_slugs(client)
|
|
|
|
# Un-retire (site owner) restores active, id intact.
|
|
r = client.post("/api/rfcs/ohm/unretire")
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["state"] == "active"
|
|
|
|
meta = entry_mod.parse(
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
|
)
|
|
assert meta.state == "active"
|
|
assert meta.id == "RFC-0042"
|
|
|
|
cached = db.conn().execute(
|
|
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
|
).fetchone()
|
|
assert cached["state"] == "active"
|
|
assert cached["rfc_id"] == "RFC-0042"
|
|
assert "ohm" in _catalog_slugs(client)
|
|
|
|
|
|
def test_rfc_owner_cannot_unretire(app_with_fake_gitea):
|
|
"""Un-retire is site-owner-only: an RFC owner who could retire cannot
|
|
bring it back (the soft-delete is recoverable only by the operator)."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="carol", role="contributor")
|
|
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
|
pitch=PITCH, owners=["carol"])
|
|
sign_in_as(client, user_id=2, gitea_login="carol",
|
|
display_name="Carol", role="contributor")
|
|
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
|
# Same RFC owner tries to un-retire → 403.
|
|
assert client.post("/api/rfcs/ohm/unretire").status_code == 403
|
|
|
|
|
|
def test_admin_retired_list_is_site_owner_only(app_with_fake_gitea):
|
|
"""GET /api/admin/retired-rfcs lists retired entries for a site owner;
|
|
an admin (who lacks un-retire authority) gets 403."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
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="carol", role="contributor")
|
|
provision_user_row(user_id=3, login="dave", role="admin")
|
|
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
|
pitch=PITCH, owners=["carol"])
|
|
sign_in_as(client, user_id=2, gitea_login="carol",
|
|
display_name="Carol", role="contributor")
|
|
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
|
|
|
# Admin: 403.
|
|
sign_in_as(client, user_id=3, gitea_login="dave",
|
|
display_name="Dave", role="admin")
|
|
assert client.get("/api/admin/retired-rfcs").status_code == 403
|
|
|
|
# Site owner: 200, sees ohm with its restore target.
|
|
sign_in_as(client, user_id=1, gitea_login="ben",
|
|
display_name="Ben", role="owner")
|
|
r = client.get("/api/admin/retired-rfcs")
|
|
assert r.status_code == 200, r.text
|
|
items = r.json()["items"]
|
|
ohm = next(i for i in items if i["slug"] == "ohm")
|
|
assert ohm["restores_to"] == "super-draft"
|
|
|
|
|
|
def test_retired_entry_refuses_discussion_reads(app_with_fake_gitea):
|
|
"""A retired entry refuses content reads of every shape (§13.7) — the
|
|
discussion/branch surfaces 404/409 rather than serve a soft-deleted RFC."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
provision_user_row(user_id=2, login="carol", role="contributor")
|
|
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
|
pitch=PITCH, owners=["carol"])
|
|
sign_in_as(client, user_id=2, gitea_login="carol",
|
|
display_name="Carol", role="contributor")
|
|
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
|
|
|
# The /main branch surface no longer serves it.
|
|
assert client.get("/api/rfcs/ohm/main").status_code in (404, 409)
|