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
+28 -1
View File
@@ -17,7 +17,18 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import api_branches, api_graduation, api_notifications, api_prs, auth, db, entry as entry_mod, cache
from . import (
api_admin,
api_branches,
api_graduation,
api_notifications,
api_prs,
auth,
db,
entry as entry_mod,
cache,
philosophy,
)
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
@@ -58,6 +69,22 @@ def make_router(
# Slice 6: §15 notifications surface (inbox, watches, prefs,
# quiet hours, per-user mute, email unsubscribe, bounce webhook).
router.include_router(api_notifications.make_router(config))
# Slice 7: §14 chrome (/philosophy read endpoint, user search for
# the §15.8 mute typeahead) and the §6/§17 admin surfaces
# (role, write-mute, audit-log, graduation-readiness queue).
router.include_router(api_admin.make_router(config))
# ---------------------------------------------------------------
# §14.2: /api/philosophy — PHILOSOPHY.md served verbatim.
# No auth gate; anonymous visitors reach `/philosophy` per §14.1.
# The body is read once at process start and refreshed on demand
# via the reconciler; see backend/app/philosophy.py.
# ---------------------------------------------------------------
@router.get("/api/philosophy")
async def get_philosophy() -> dict[str, Any]:
payload = philosophy.load()
return {"body": payload["body"]}
# ---------------------------------------------------------------
# Auth surface — extends the prototype's pattern but reads role
+397
View File
@@ -0,0 +1,397 @@
"""§17 admin endpoints + Slice 7 user search.
The admin's repertoire grew across the prior slices without earning a
centralized home: role grants (§6.1), the §6.2 app-wide write-mute, the
§6.5 / §5 audit logs, and the §13 graduation-readiness queue. Slice 7
consolidates them behind `/api/admin/*` so the chrome can hold them in
one tabbed surface (`Admin.jsx`).
The endpoints in this module:
- `GET /api/admin/users` — list users with role + mute
- `POST /api/admin/users/<id>/role` — set role per §6.1
- `POST /api/admin/users/<id>/mute` — set the §6.2 write-mute
- `GET /api/admin/audit` — paged `actions` log
- `GET /api/admin/permission-events` — paged `permission_events` log
- `GET /api/admin/graduation-queue` — super-drafts ready to graduate
- `GET /api/users/search?q=…` — typeahead for §15.8 mute add
Permission gates: every `/api/admin/*` endpoint requires
`require_admin` (owner or admin); the role-change endpoint additionally
refuses any non-owner caller granting `owner`, since owner-zero is the
only owner bootstrap path per §6.1 and a downgrade from owner needs the
sitting owner's hand. The user-search endpoint is open to any
authenticated viewer — it powers the §15.8 mute typeahead and contains
no privileged information beyond what every authenticated viewer
already sees in the catalog and chat-author surfaces.
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from . import auth, db
from .config import Config
# ---------------------------------------------------------------------------
# Pydantic bodies
# ---------------------------------------------------------------------------
class RoleBody(BaseModel):
role: str = Field(pattern="^(owner|admin|contributor)$")
class MuteBody(BaseModel):
muted: bool
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
def make_router(config: Config) -> APIRouter:
del config # unused for now; reserved for future per-deployment knobs
router = APIRouter()
# ----- User listing -----
@router.get("/api/admin/users")
async def list_users(request: Request) -> dict[str, Any]:
auth.require_admin(request)
rows = db.conn().execute(
"""
SELECT id, gitea_login, display_name, email, role, muted,
created_at, last_seen_at
FROM users
ORDER BY role = 'owner' DESC, role = 'admin' DESC, display_name COLLATE NOCASE
"""
).fetchall()
return {
"items": [
{
"id": r["id"],
"gitea_login": r["gitea_login"],
"display_name": r["display_name"],
"email": r["email"] or "",
"role": r["role"],
"muted": bool(r["muted"]),
"created_at": r["created_at"],
"last_seen_at": r["last_seen_at"],
}
for r in rows
]
}
# ----- Role change (§6.1) -----
@router.post("/api/admin/users/{user_id}/role")
async def set_role(user_id: int, body: RoleBody, request: Request) -> dict[str, Any]:
viewer = auth.require_admin(request)
target = db.conn().execute(
"SELECT id, role, gitea_login FROM users WHERE id = ?", (user_id,)
).fetchone()
if target is None:
raise HTTPException(404, "User not found")
# §6.1: only an owner can grant `owner`, and only an owner can
# revoke an owner. Admins can flip contributor ↔ admin freely.
if body.role == "owner" and viewer.role != "owner":
raise HTTPException(403, "Only an owner can grant the owner role")
if target["role"] == "owner" and viewer.role != "owner":
raise HTTPException(403, "Only an owner can change an owner's role")
# An owner cannot demote themselves — that would orphan owner-zero
# if they are the only owner, and the role-change UI should not
# smuggle a "downgrade self" path through this endpoint.
if target["id"] == viewer.user_id and body.role != viewer.role:
raise HTTPException(403, "Use the explicit succession path to change your own role")
before = target["role"]
if before == body.role:
return {"ok": True, "role": body.role, "changed": False}
db.conn().execute(
"UPDATE users SET role = ? WHERE id = ?", (body.role, user_id),
)
db.conn().execute(
"""
INSERT INTO permission_events
(actor_user_id, subject_user_id, event_kind, details)
VALUES (?, ?, 'role_changed', ?)
""",
(
viewer.user_id,
user_id,
json.dumps({"before": before, "after": body.role}),
),
)
return {"ok": True, "role": body.role, "changed": True}
# ----- Write-mute (§6.2) -----
@router.post("/api/admin/users/{user_id}/mute")
async def set_mute(user_id: int, body: MuteBody, request: Request) -> dict[str, Any]:
viewer = auth.require_admin(request)
target = db.conn().execute(
"SELECT id, role, muted FROM users WHERE id = ?", (user_id,)
).fetchone()
if target is None:
raise HTTPException(404, "User not found")
if target["id"] == viewer.user_id:
raise HTTPException(422, "You cannot write-mute yourself")
# §6.2 + §6.1: admins/owners are not write-mutable. The write-mute
# is a contributor-only refusal — an admin's authority is the
# role-change channel, not a mute.
if target["role"] in ("owner", "admin"):
raise HTTPException(
403,
"Owners and admins cannot be write-muted — change the role instead",
)
before = bool(target["muted"])
after = bool(body.muted)
if before == after:
return {"ok": True, "muted": after, "changed": False}
db.conn().execute(
"UPDATE users SET muted = ? WHERE id = ?", (1 if after else 0, user_id),
)
db.conn().execute(
"""
INSERT INTO permission_events
(actor_user_id, subject_user_id, event_kind, details)
VALUES (?, ?, ?, ?)
""",
(
viewer.user_id,
user_id,
"muted" if after else "restored",
json.dumps({"before": before, "after": after}),
),
)
return {"ok": True, "muted": after, "changed": True}
# ----- Audit log (`actions` + `permission_events`) -----
@router.get("/api/admin/audit")
async def list_audit(
request: Request,
action_kind: str | None = None,
actor_user_id: int | None = None,
rfc_slug: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
before_id: int | None = None,
) -> dict[str, Any]:
auth.require_admin(request)
clauses: list[str] = []
args: list[Any] = []
if action_kind:
clauses.append("a.action_kind = ?")
args.append(action_kind)
if actor_user_id is not None:
clauses.append("a.actor_user_id = ?")
args.append(actor_user_id)
if rfc_slug:
clauses.append("a.rfc_slug = ?")
args.append(rfc_slug)
if before_id is not None:
clauses.append("a.id < ?")
args.append(before_id)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
rows = db.conn().execute(
f"""
SELECT a.id, a.actor_user_id, a.on_behalf_of, a.action_kind,
a.rfc_slug, a.branch_name, a.pr_number, a.bot_commit_sha,
a.details, a.created_at,
u.gitea_login AS actor_login, u.display_name AS actor_display
FROM actions a
LEFT JOIN users u ON u.id = a.actor_user_id
{where}
ORDER BY a.id DESC
LIMIT ?
""",
(*args, limit),
).fetchall()
# The distinct-action-kinds list powers the filter chip in
# `Admin.jsx`; cheap to compute alongside the page since the
# action_kind set is bounded.
kinds = [
r["action_kind"]
for r in db.conn().execute(
"SELECT DISTINCT action_kind FROM actions ORDER BY action_kind"
)
]
return {
"items": [
{
"id": r["id"],
"action_kind": r["action_kind"],
"actor_user_id": r["actor_user_id"],
"actor_login": r["actor_login"],
"actor_display": r["actor_display"],
"on_behalf_of": r["on_behalf_of"],
"rfc_slug": r["rfc_slug"],
"branch_name": r["branch_name"],
"pr_number": r["pr_number"],
"bot_commit_sha": r["bot_commit_sha"],
"details": _safe_json(r["details"]),
"created_at": r["created_at"],
}
for r in rows
],
"action_kinds": kinds,
"has_more": len(rows) == limit,
}
@router.get("/api/admin/permission-events")
async def list_permission_events(
request: Request,
limit: int = Query(default=100, ge=1, le=500),
before_id: int | None = None,
) -> dict[str, Any]:
auth.require_admin(request)
clauses: list[str] = []
args: list[Any] = []
if before_id is not None:
clauses.append("p.id < ?")
args.append(before_id)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
rows = db.conn().execute(
f"""
SELECT p.id, p.event_kind, p.details, p.created_at,
p.actor_user_id, p.subject_user_id,
au.gitea_login AS actor_login, au.display_name AS actor_display,
su.gitea_login AS subject_login, su.display_name AS subject_display
FROM permission_events p
LEFT JOIN users au ON au.id = p.actor_user_id
LEFT JOIN users su ON su.id = p.subject_user_id
{where}
ORDER BY p.id DESC
LIMIT ?
""",
(*args, limit),
).fetchall()
return {
"items": [
{
"id": r["id"],
"event_kind": r["event_kind"],
"actor_login": r["actor_login"],
"actor_display": r["actor_display"],
"subject_login": r["subject_login"],
"subject_display": r["subject_display"],
"details": _safe_json(r["details"]),
"created_at": r["created_at"],
}
for r in rows
],
"has_more": len(rows) == limit,
}
# ----- Graduation-readiness queue (§13.2) -----
@router.get("/api/admin/graduation-queue")
async def graduation_queue(request: Request) -> dict[str, Any]:
auth.require_admin(request)
# §13 / §13.2: a super-draft is ready when (a) it has at least one
# owner claimed via §13.1 and (b) it has zero open meta_body_edit
# PRs. We compute both with one pass, returning the ready set and
# the not-yet-ready set so the admin sees what is gating each.
rows = db.conn().execute(
"""
SELECT slug, title, owners_json, arbiters_json, tags_json,
proposed_at, last_entry_commit_at
FROM cached_rfcs
WHERE state = 'super-draft'
ORDER BY COALESCE(last_entry_commit_at, proposed_at) DESC
"""
).fetchall()
items_ready: list[dict[str, Any]] = []
items_blocked: list[dict[str, Any]] = []
for r in rows:
owners = json.loads(r["owners_json"] or "[]")
blocking = db.conn().execute(
"""
SELECT COUNT(*) AS n FROM cached_prs
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
""",
(r["slug"],),
).fetchone()["n"]
payload = {
"slug": r["slug"],
"title": r["title"],
"owners": owners,
"tags": json.loads(r["tags_json"] or "[]"),
"proposed_at": r["proposed_at"],
"last_entry_commit_at": r["last_entry_commit_at"],
"blocking_prs": blocking,
"owners_set": len(owners) > 0,
}
if payload["owners_set"] and blocking == 0:
items_ready.append(payload)
else:
items_blocked.append(payload)
return {"ready": items_ready, "blocked": items_blocked}
# ----- User search (typeahead for §15.8 mute add) -----
@router.get("/api/users/search")
async def search_users(
request: Request, q: str = Query(default="", min_length=0, max_length=80),
) -> dict[str, Any]:
viewer = auth.require_user(request)
needle = q.strip().lower()
# An empty query returns recent users; a non-empty query matches
# against gitea_login and display_name with a prefix-first
# ranking so the typeahead surfaces obvious matches early.
if not needle:
rows = db.conn().execute(
"""
SELECT id, gitea_login, display_name, role
FROM users WHERE id != ?
ORDER BY last_seen_at DESC LIMIT 10
""",
(viewer.user_id,),
).fetchall()
else:
like = f"%{needle}%"
prefix = f"{needle}%"
rows = db.conn().execute(
"""
SELECT id, gitea_login, display_name, role
FROM users
WHERE id != ?
AND (LOWER(gitea_login) LIKE ? OR LOWER(display_name) LIKE ?)
ORDER BY
CASE WHEN LOWER(gitea_login) LIKE ? THEN 0 ELSE 1 END,
LOWER(gitea_login)
LIMIT 10
""",
(viewer.user_id, like, like, prefix),
).fetchall()
return {
"items": [
{
"id": r["id"],
"gitea_login": r["gitea_login"],
"display_name": r["display_name"],
"role": r["role"],
}
for r in rows
]
}
return router
def _safe_json(blob: str | None) -> Any:
if not blob:
return None
try:
return json.loads(blob)
except Exception:
return blob
+30
View File
@@ -293,6 +293,36 @@ def make_router(config: Config) -> APIRouter:
# ----- Per-user notification mute (§15.8) -----
@router.get("/api/users/me/notification-mutes")
async def list_user_mutes(request: Request) -> dict[str, Any]:
"""Slice 7: the §15.8 mute list the settings surface renders.
Joined against users so the settings UI can show @gitea_login and
display_name without a second round-trip.
"""
viewer = auth.require_user(request)
rows = db.conn().execute(
"""
SELECT m.muted_user_id, m.muted_at,
u.gitea_login, u.display_name
FROM notification_user_mutes m
JOIN users u ON u.id = m.muted_user_id
WHERE m.muter_user_id = ?
ORDER BY m.muted_at DESC
""",
(viewer.user_id,),
).fetchall()
return {
"items": [
{
"muted_user_id": r["muted_user_id"],
"gitea_login": r["gitea_login"],
"display_name": r["display_name"],
"muted_at": r["muted_at"],
}
for r in rows
]
}
@router.post("/api/users/{user_id}/notification-mute")
async def add_user_mute(user_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
+66
View File
@@ -0,0 +1,66 @@
"""§14.2 philosophy source.
The spec names PHILOSOPHY.md as the body the `/philosophy` route renders,
"sourced from the meta repo's main branch, cached and refreshed on the
same cadence as RFC bodies (§4)." Slice 7 picks the disk-first shape:
the file is checked into the app repo alongside SPEC.md, since it is
the framework's design document rather than an RFC entry, and reading
it from disk at process start (with a periodic re-read for hot edits)
puts the framework's mission in front of the reader without an extra
Gitea round-trip on the first hit.
If a downstream deployment hosts PHILOSOPHY.md in the meta repo
instead, the `PHILOSOPHY_PATH` env var can point at a working-tree
clone or a sync target; the loader does not care which.
"""
from __future__ import annotations
import logging
import os
import threading
from pathlib import Path
log = logging.getLogger(__name__)
_DEFAULT_PATH = Path(__file__).resolve().parents[2] / "PHILOSOPHY.md"
_lock = threading.Lock()
_cache: dict | None = None
def _resolved_path() -> Path:
override = os.environ.get("PHILOSOPHY_PATH", "").strip()
if override:
return Path(override).expanduser().resolve()
return _DEFAULT_PATH
def load(force: bool = False) -> dict:
"""Return the cached `{body, path, mtime}` payload, reading from disk
on first call or when `force=True`. The reconciler's sweep can call
this with `force=True` to pick up out-of-band edits.
"""
global _cache
with _lock:
if _cache is not None and not force:
return _cache
path = _resolved_path()
try:
text = path.read_text(encoding="utf-8")
mtime = path.stat().st_mtime
except FileNotFoundError:
log.warning("PHILOSOPHY.md not found at %s — serving placeholder", path)
text = (
"# PHILOSOPHY.md not found\n\n"
"The deployment is missing its philosophy document. Set "
"PHILOSOPHY_PATH or place PHILOSOPHY.md at the project root."
)
mtime = 0.0
_cache = {"body": text, "path": str(path), "mtime": mtime}
return _cache
def refresh() -> dict:
"""Force-reread from disk. Returns the new payload."""
return load(force=True)
+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])