Files
rfc-app/backend/tests/test_retire_vertical.py
T
Ben Stull aee9b582e5 fix(slice4): make all entry write paths sidecar-aware (§22.4a carried from SLICE-1)
graduate, claim, retire/unretire, _read_meta_entry, mark_entry_reviewed,
body extract/wrap (api_branches + api_prs replay) now dual-read and write
metadata to the sidecar via write_entry_files + bot.commit_entry_files/
open_entry_pr — a migrated body-only .md no longer crashes entry.parse or
re-grows frontmatter; legacy entries lazy-migrate on first metadata write.
Existing tests updated to assert the sidecar (INV-2 clean docs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:57:54 -07:00

262 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"
# §22.4a SLICE-4: the state flip lands in the metadata sidecar and the
# `.md` is lazy-migrated to a clean body-only file (INV-2). Fields kept.
import yaml as _yaml
sc = _yaml.safe_load(
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.meta.yaml")]["content"]
)
assert sc["state"] == "retired"
assert "carol" in sc["owners"]
assert "---" not in fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
# 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"
import yaml as _yaml
sc = _yaml.safe_load(
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.meta.yaml")]["content"]
)
assert sc["state"] == "active"
assert sc["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)