"""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/` 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/` 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/` 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)