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.
81 lines
6.7 KiB
Python
81 lines
6.7 KiB
Python
"""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")
|