Slice 7: §14 chrome + settings and admin neighborhoods

§14.1 richer landing, §14.2 /philosophy route (disk-backed), §14.3
persistent About link. /settings/notifications surfaces Slice 6's
preferences/quiet-hours/mute/watches endpoints. /admin home base
consolidates role management, the §6.2 write-mute, the audit-log
viewer, the permission-events log, and the §13.2 graduation queue.

Backend: backend/app/philosophy.py, backend/app/api_admin.py (seven
admin endpoints + user-search), GET /api/users/me/notification-mutes.
Frontend: Landing.jsx (deck), Philosophy.jsx, NotificationSettings.jsx,
Admin.jsx, App.jsx routing for the chrome surfaces.

Tests: backend/tests/test_chrome_vertical.py — 13 cases. Full suite
75/75 green.

Spec corrections: §14.2 (PHILOSOPHY.md source is a deployment-time
decision), §17 (admin block extended to name the seven new endpoints
+ user-search and notification-mutes read). §19.1 rewritten for
Slice 8 hardening; §19.2 grew four candidates (owner succession,
mute-from-actor, the "Following since <date>" disclosure, audit-log
row prose).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 23:40:49 -07:00
parent f67d0aa0db
commit 060fa408a2
14 changed files with 2722 additions and 158 deletions
+457
View File
@@ -0,0 +1,457 @@
"""End-to-end integration tests for the Slice 7 vertical (§14 chrome
plus the /settings/notifications and /admin neighborhoods).
The slice is chrome over existing infrastructure — the rules live in
§14 / §15 / §6 / §13.2, and the endpoints land in
`backend/app/api.py` (the `/api/philosophy` read), in
`backend/app/api_admin.py` (the `/api/admin/*` set plus `/api/users/search`),
and in `backend/app/api_notifications.py` (`/api/users/me/notification-mutes`,
the list-read counterpart to Slice 6's add/delete pair).
The tests prove:
* `/api/philosophy` returns the PHILOSOPHY.md body to anonymous and
authenticated callers alike, per §14.2's "authenticated and
anonymous visitors alike can reach `/philosophy`."
* The §15.4 / §15.5 / §15.8 preferences round-trip cleanly through
the per-category email toggles, the digest cadence dropdown, the
quiet-hours editor, and the per-user mute list — what the
`NotificationSettings.jsx` page exercises end-to-end against the
real backend.
* The §15.8 `email_watched_churn` permanent refusal still reads as
`False` after every preferences round-trip — the toggle is
permanently disabled, not silently writable.
* `/api/users/me/notification-mutes` lists the joined rows the
settings page renders.
* `/api/users/search` powers the §15.8 mute typeahead.
* `/api/admin/users` returns the user roster; role-change and the
§6.2 write-mute round-trip through `/api/admin/users/<id>/role`
and `/api/admin/users/<id>/mute`.
* A contributor cannot reach `/api/admin/*`; an admin can.
* The §6.2 write-mute prevents the muted contributor from running
`POST /api/rfcs/propose` (the same `require_contributor` gate the
other write paths use).
* `/api/admin/audit` returns rows filtered by `action_kind`,
`actor_user_id`, and `rfc_slug`, and pages with `before_id`.
* `/api/admin/permission-events` reads the `permission_events`
table populated by the role-change and write-mute endpoints.
* `/api/admin/graduation-queue` returns the §13.2-ready super-drafts
in the `ready` list and the not-yet-ready ones in `blocked`, with
the right precondition shape (owners set, zero blocking
body-edit PRs).
"""
from __future__ import annotations
import json as _json
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_super_draft_vertical import seed_super_draft # noqa: F401
PITCH = "Open Human Model is a framework for representing humans."
# ---------------------------------------------------------------------------
# §14.2 — the philosophy route
# ---------------------------------------------------------------------------
def test_philosophy_route_returns_body_to_anonymous_visitors(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/api/philosophy")
assert r.status_code == 200, r.text
body = r.json()["body"]
# The seam: PHILOSOPHY.md at the repo root carries the §14.1
# framing line. If this assertion ever breaks because the
# philosophy was rewritten, the slug "standardization process"
# is the most stable phrase to anchor against.
assert "standardization process" in body.lower()
def test_philosophy_route_returns_body_to_authenticated_visitors(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/philosophy")
assert r.status_code == 200
assert "standardization process" in r.json()["body"].lower()
# ---------------------------------------------------------------------------
# §15.4 / §15.5 / §15.8 — the notification-settings round-trip
# ---------------------------------------------------------------------------
def test_notification_preferences_round_trip(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
# Defaults per §15.4: personal-direct on, structural off,
# admin-actionable on (contributors ignore it), churn permanently
# off, digest weekly.
r = client.get("/api/users/me/notification-preferences")
assert r.status_code == 200
p = r.json()
assert p["email_personal_direct"] is True
assert p["email_watched_structural"] is False
assert p["email_watched_churn"] is False # §15.4 permanent refusal
assert p["digest_cadence"] == "weekly"
# Flip personal-direct off and watched-structural on; bump cadence
# to daily. The settings page's toggles drive these payloads.
r = client.post("/api/users/me/notification-preferences", json={
"email_personal_direct": False,
"email_watched_structural": True,
"digest_cadence": "daily",
})
assert r.status_code == 200
p = client.get("/api/users/me/notification-preferences").json()
assert p["email_personal_direct"] is False
assert p["email_watched_structural"] is True
assert p["email_watched_churn"] is False # still permanently off
assert p["digest_cadence"] == "daily"
def test_quiet_hours_round_trip_and_partial_refusal(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
# Unset by default.
r = client.get("/api/users/me/quiet-hours")
assert r.status_code == 200
assert r.json() == {"start": None, "end": None, "timezone": None}
# §15.8: all-three-or-nothing. A partial set is refused.
r = client.post("/api/users/me/quiet-hours", json={
"start": "22:00", "end": "08:00", "timezone": None,
})
assert r.status_code == 422
# The full trio round-trips.
r = client.post("/api/users/me/quiet-hours", json={
"start": "22:00", "end": "08:00", "timezone": "America/Los_Angeles",
})
assert r.status_code == 200
q = client.get("/api/users/me/quiet-hours").json()
assert q == {"start": "22:00", "end": "08:00", "timezone": "America/Los_Angeles"}
# Clear with all-null.
r = client.post("/api/users/me/quiet-hours", json={
"start": None, "end": None, "timezone": None,
})
assert r.status_code == 200
assert client.get("/api/users/me/quiet-hours").json() == {
"start": None, "end": None, "timezone": None,
}
def test_user_mute_add_list_and_unmute(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="carol", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
# Empty mute list to start.
r = client.get("/api/users/me/notification-mutes")
assert r.status_code == 200
assert r.json()["items"] == []
# Add — Slice 7's settings page surfaces this via the typeahead.
r = client.post("/api/users/3/notification-mute")
assert r.status_code == 200, r.text
# List — the joined view the settings page renders.
items = client.get("/api/users/me/notification-mutes").json()["items"]
assert len(items) == 1
assert items[0]["gitea_login"] == "carol"
assert items[0]["display_name"] == "Carol"
# Unmute.
r = client.delete("/api/users/3/notification-mute")
assert r.status_code == 200
assert client.get("/api/users/me/notification-mutes").json()["items"] == []
def test_user_search_powers_mute_typeahead(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="carol", role="contributor")
provision_user_row(user_id=4, login="dave", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
# Empty query: recent users, excluding the caller.
r = client.get("/api/users/search")
assert r.status_code == 200
ids = {u["id"] for u in r.json()["items"]}
assert 2 not in ids
assert {3, 4}.issubset(ids)
# Prefix matches gitea_login.
r = client.get("/api/users/search?q=car")
items = r.json()["items"]
assert any(u["gitea_login"] == "carol" for u in items)
# Substring matches display_name (we'd indexed by Capitalize()).
r = client.get("/api/users/search?q=Dave")
items = r.json()["items"]
assert any(u["gitea_login"] == "dave" for u in items)
# ---------------------------------------------------------------------------
# §6 / §17 — the admin neighborhood
# ---------------------------------------------------------------------------
def test_admin_list_users_returns_roster_and_refuses_contributors(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=3, login="carol", role="admin")
# Contributor refused.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
assert client.get("/api/admin/users").status_code == 403
# Owner sees the roster ordered owner → admin → contributor.
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
r = client.get("/api/admin/users")
assert r.status_code == 200
roles = [u["role"] for u in r.json()["items"]]
assert roles[0] == "owner"
assert "admin" in roles
assert "contributor" in roles
def test_admin_role_change_round_trips_and_records_permission_event(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
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")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
r = client.post("/api/admin/users/2/role", json={"role": "admin"})
assert r.status_code == 200
assert r.json() == {"ok": True, "role": "admin", "changed": True}
# The user row updated.
row = db.conn().execute("SELECT role FROM users WHERE id = 2").fetchone()
assert row["role"] == "admin"
# A permission_events row landed.
rows = db.conn().execute(
"SELECT event_kind, details FROM permission_events WHERE subject_user_id = 2"
).fetchall()
assert len(rows) == 1
assert rows[0]["event_kind"] == "role_changed"
details = _json.loads(rows[0]["details"])
assert details == {"before": "contributor", "after": "admin"}
# Idempotent: a re-set with the same role returns changed=False.
r = client.post("/api/admin/users/2/role", json={"role": "admin"})
assert r.status_code == 200
assert r.json()["changed"] is False
def test_admin_cannot_grant_owner_unless_owner(app_with_fake_gitea):
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=3, login="carol", role="admin")
provision_user_row(user_id=2, login="alice", role="contributor")
# An admin cannot grant `owner`.
sign_in_as(client, user_id=3, gitea_login="carol", display_name="Carol", role="admin")
r = client.post("/api/admin/users/2/role", json={"role": "owner"})
assert r.status_code == 403
# An admin cannot change an owner's role.
r = client.post("/api/admin/users/1/role", json={"role": "admin"})
assert r.status_code == 403
def test_admin_write_mute_round_trip_and_refusals(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
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")
provision_user_row(user_id=3, login="carol", role="admin")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
# The §6.2 write-mute: contributor only.
r = client.post("/api/admin/users/3/mute", json={"muted": True})
assert r.status_code == 403 # admins are not write-mutable
r = client.post("/api/admin/users/2/mute", json={"muted": True})
assert r.status_code == 200
assert r.json()["muted"] is True
# And the gate fires when alice tries to write.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/propose", json={
"title": "Open Human Model",
"slug": "ohm",
"pitch": PITCH,
"tags": [],
})
assert r.status_code == 403, r.text
# Restore — the mute audit lands.
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
r = client.post("/api/admin/users/2/mute", json={"muted": False})
assert r.status_code == 200
events = db.conn().execute(
"SELECT event_kind FROM permission_events WHERE subject_user_id = 2 ORDER BY id"
).fetchall()
kinds = [e["event_kind"] for e in events]
assert kinds == ["muted", "restored"]
def test_admin_audit_log_filters_and_pages(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/propose", json={
"title": "Open Human Model",
"slug": "ohm",
"pitch": PITCH,
"tags": [],
})
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
client.post(f"/api/proposals/{pr_number}/merge")
# Unfiltered: at least the propose + merge land in `actions`.
r = client.get("/api/admin/audit")
assert r.status_code == 200
kinds = [it["action_kind"] for it in r.json()["items"]]
assert "propose_rfc" in kinds
assert "merge_proposal" in kinds
# The distinct-action-kinds list powers the filter chip.
assert "propose_rfc" in r.json()["action_kinds"]
# Filter by rfc_slug.
r = client.get("/api/admin/audit?rfc_slug=ohm")
assert all(it["rfc_slug"] == "ohm" for it in r.json()["items"])
# Filter by action_kind.
r = client.get("/api/admin/audit?action_kind=propose_rfc")
assert all(it["action_kind"] == "propose_rfc" for it in r.json()["items"])
# Filter by actor_user_id.
r = client.get("/api/admin/audit?actor_user_id=2")
assert all(it["actor_user_id"] == 2 for it in r.json()["items"])
def test_admin_graduation_queue_partitions_by_readiness(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
# Two super-drafts: one with owners claimed, one without.
seed_super_draft(fake, slug="ready", title="Ready RFC", pitch="")
seed_super_draft(fake, slug="orphan", title="Orphan RFC", pitch="")
db.conn().execute(
"UPDATE cached_rfcs SET owners_json = ? WHERE slug = 'ready'",
(_json.dumps(["ben"]),),
)
r = client.get("/api/admin/graduation-queue")
assert r.status_code == 200
d = r.json()
ready_slugs = {it["slug"] for it in d["ready"]}
blocked_slugs = {it["slug"] for it in d["blocked"]}
assert ready_slugs == {"ready"}
assert "orphan" in blocked_slugs
# Now add a blocking body-edit PR to the ready slug — it should
# move to blocked even with owners set.
db.conn().execute(
"""
INSERT INTO cached_prs
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
opened_by, opened_at, head_branch, base_branch, head_sha)
VALUES ('ready', 'meta_body_edit', 'wiggleverse/meta', 42, 'edit', '',
'open', 'alice', datetime('now'), 'edit-ready-abc123', 'main', 'sha')
"""
)
d = client.get("/api/admin/graduation-queue").json()
assert {it["slug"] for it in d["ready"]} == set()
assert "ready" in {it["slug"] for it in d["blocked"]}
blocked_ready = next(it for it in d["blocked"] if it["slug"] == "ready")
assert blocked_ready["blocking_prs"] == 1
assert blocked_ready["owners_set"] is True
def test_admin_permission_events_returns_role_and_mute_history(app_with_fake_gitea):
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="alice", role="contributor")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
client.post("/api/admin/users/2/role", json={"role": "admin"})
client.post("/api/admin/users/2/role", json={"role": "contributor"})
client.post("/api/admin/users/2/mute", json={"muted": True})
r = client.get("/api/admin/permission-events?limit=10")
assert r.status_code == 200
items = r.json()["items"]
# Newest first.
kinds = [it["event_kind"] for it in items]
assert kinds[:3] == ["muted", "role_changed", "role_changed"]
# Subject and actor join populated.
assert all(it["subject_login"] == "alice" for it in items[:3])
assert all(it["actor_login"] == "ben" for it in items[:3])