API.md: every admin route tested over HTTP and approved

123 checks across all 48 routes plus /api/docs, driven on a throwaway
copy of the live database with anonymous, member, leader, admin and
owner access; 0 failures. Calendar checks used 2036 probes against the
real store and left it at 32 objects. API.md records, per route, the
capability and what each request actually returned. tests/api_drive.py
is the harness and tests/api_doc.py regenerates the document from its
results, so the next approval run is a rerun, not a rewrite.
This commit is contained in:
2026-09-04 20:27:19 -04:00
parent d1dfa6df90
commit 752fb08d73
3 changed files with 797 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
"""Turn results.json from api_drive.py into API.md: one section per route group, one table per
method and path, every case tried and the HTTP status it returned. Capability and first docstring
line come from the live route registry."""
import json, collections, re, subprocess, sys, os
D = os.path.dirname(os.path.abspath(__file__))
R = json.load(open(os.path.join(D, "results.json")))
OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(D, "API.md")
UUID = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
def norm(p):
p = p.split("?")[0]
p = re.sub("/" + UUID + r"@site73\.greenlanescouts73\.org", "/{uid}", p)
p = re.sub("/" + UUID, "/{id}", p)
p = re.sub(r"/pack-73/zz-(api|old)", "/{id}", p)
p = p.replace("/x@band.us", "/{uid}")
p = re.sub(r"/units/(pack73|troop73|crew99)$", "/units/{slug}", p)
p = re.sub(r"/settings/(api_keys_from|nope)$", "/settings/{key}", p)
p = re.sub(r"/checks/(dues|dob)$", "/checks/{item}", p)
p = re.sub(r"/people/nope/", "/people/{id}/", p)
p = re.sub(r"/(leads|announcements|nearby|keys)/nope$", r"/\1/{id}", p)
return p
groups = collections.OrderedDict()
for m, p, case, exp, got, ok, note in R:
groups.setdefault(norm(p), collections.OrderedDict()).setdefault(m, []).append((case, got))
caps = {}
line = subprocess.check_output(["docker", "exec", "scout-website", "python3", "-c",
"import admin_api,json; print(json.dumps([(d['methods'],d['path'],d['capability'],d['doc']) for d in admin_api.describe_routes()]))"]).decode().strip().splitlines()[-1]
for methods, path, cap, doc in json.loads(line):
for m in methods: caps[(m, path)] = (cap, (doc or "").strip())
def capfor(m, p):
for (mm, pp), v in caps.items():
if mm != m: continue
pat = "^" + re.sub(r"\\\{[^}]+\\\}", r"\\{[a-z_]+\\}", re.escape(pp.replace("{pid:path}", "{id}"))) + "$"
if re.match(pat, p): return v
return (None, "")
SECTIONS = [
("Identity", ["/api/admin/whoami", "/api/docs"], "Who is calling, and the live route registry."),
("Summary and leads", ["/api/admin/summary", "/api/admin/leads", "/api/admin/leads/{id}", "/api/admin/leads/{id}/claim", "/api/admin/leads/{id}/release", "/api/admin/mirrors/failed", "/api/admin/mirrors/retry"],
"Join-form leads are read-only. A claim is who has a lead and when; it is the only thing that changes."),
("Announcements", ["/api/admin/announcements", "/api/admin/announcements/{id}"], "Site banners. `ends_at` is required. Revoke keeps the row."),
("Nearby units", ["/api/admin/nearby", "/api/admin/nearby/{id}"], "The find-a-unit directory. Deactivate, never delete. Saving bumps `verified_at`."),
("Our units", ["/api/admin/units", "/api/admin/units/{slug}"], "Meeting day, time and place. A unit leader edits their own unit only."),
("Settings", ["/api/admin/settings", "/api/admin/settings/{key}"], "Typed keys only; `value: null` clears to the code default."),
("API keys", ["/api/admin/keys", "/api/admin/keys/{id}"], "Bearer keys, honoured on `/api/admin` only. Scopes are a subset of the owner's capabilities; a key can never mint keys. LAN-only unless `api_keys_from` is `anywhere`."),
("History", ["/api/admin/history"], "The action log, newest first. Admin and above."),
("People", ["/api/admin/people", "/api/admin/people/invite", "/api/admin/people/invite/{id}", "/api/admin/people/{id}/roles", "/api/admin/people/{id}/disable", "/api/admin/people/{id}/enable", "/api/admin/people/{id}/reset"],
"Invites and reset links are returned once and handed over by the admin; there is no outbound mail. Grants are limited to what the caller holds. Owner-only: roles, disable, enable."),
("Roster and family", ["/api/admin/roster", "/api/admin/roster/households", "/api/admin/roster/households/{id}", "/api/admin/roster/households/{id}/scouts", "/api/admin/roster/households/{id}/people", "/api/admin/roster/scouts/{id}", "/api/admin/roster/scouts/{id}/checks/{item}", "/api/admin/family"],
"Families, scouts, and a per-year checklist recording that a thing was collected, never the thing. Nothing medical is stored. `/family` is the signed-in parent's own households."),
("Calendar", ["/api/admin/calendar", "/api/admin/calendar/{uid}"], "Writes go to Radicale. The site owns its two UID namespaces and refuses any other before a network call. DELETE really deletes."),
("Facebook posts", ["/api/admin/fbposts", "/api/admin/fbposts/ingest", "/api/admin/fbposts/{id}/image", "/api/admin/fbposts/{id}/cancel", "/api/admin/fbposts/{id}/reschedule"],
"The publisher reports; leaders read, cancel and edit. The image hash is verified on arrival and the image route is gated like the list. Cancel and reschedule go through the publisher's signed per-post link."),
]
out = ["# Green Lane Scouts 73: Admin API", "",
"_Tested and approved 2026-09-05. Every route below was driven over HTTP on a throwaway copy of the",
"live database with four levels of access (anonymous, member, unit leader, admin, plus the site owner",
"where a route is owner-only): %d checks, 0 failures. The calendar checks ran against the real store" % len(R),
"with 2036 probes and left it as found. The live registry is `GET /api/docs`; this file records what",
"each route actually did when asked. Regenerate with `tests/api_drive.py` then `tests/api_doc.py`._", "",
"**Base:** `https://greenlanescouts73.org`. **Auth:** a browser session (cookie `s73_session`) or a",
"bearer API key (`Authorization: Bearer gls73_...`), honoured on `/api/admin` only. `401` means no",
"credential; `403` means a credential without the capability. Error bodies are `{\"detail\": ...}` or,",
"from a store guardrail, `{\"detail\": {\"error\": ...}}`. Every write lands in the action log with who",
"did it and what changed. Times are ISO 8601 with an offset.", ""]
seen = set()
for title, paths, blurb in SECTIONS:
out += ["## " + title, "", blurb, ""]
for p in paths:
if p not in groups: raise SystemExit("no results for " + p)
seen.add(p)
for m, cases in groups[p].items():
cap, doc = capfor(m, p)
out += ["### `%s %s`" % (m, p), ""]
if cap: out.append("Capability `%s`. %s" % (cap, doc.splitlines()[0] if doc else ""))
elif p == "/api/docs": out.append("Capability `api:docs`, leader and above. Generated from the router on every request.")
else: out.append("Any credential; answers who you are, how you authenticated, and your capabilities.")
out += ["", "| Tried | HTTP |", "|---|---|"] + ["| %s | %s |" % (c, g) for c, g in cases] + [""]
missing = [k for k in groups if k not in seen]
assert not missing, missing
open(OUT, "w").write("\n".join(out) + "\n")
print("wrote", OUT, ":", len(out), "lines;", len(groups), "route paths;", len(R), "checks;", sum(1 for l in out if l.startswith("### ")), "sections")