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:
+28
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user