P4: calendar write-back to Radicale, site-owned UIDs only

The site becomes a second writer to scouts/site73, as the scoped scoutsite
principal (rw on that one collection, denied everywhere else by the rights
file). Two rules enforced in calendar_write.py, not left to callers: the
site owns only UIDs ending @site73.greenlanescouts73.org and refuses any
other before a network call, the same shape as band-cal-sync and @band.us;
and with no RADICALE_* configuration every write is a 503, never a silent
no-op.

The VEVENT layout matches seed.py exactly so the feed reverses it into the
row shape the public pages already render: all-day DTEND exclusive, TZID +
VTIMEZONE on timed events, 90-minute default for a timed one-day event,
noon on the end date for a timed multi-day one, CATEGORIES for the unit,
X-SCOUT73-BADGE, 75-octet folding. Reads come from the scout-calendar feed
(now carrying uid and recurring); rows the site created and that are not
part of a series are marked mine. Writes are logged to auth_events and
bust the page cache so a leader sees their event within the feed's minute.

DELETE really deletes - the calendar's history is Radicale's git log.
Endpoints under calendar:write. tests/smoke_admin.py 78 -> 102. Proven
against the real store on a 2036 probe (outside the feed window): create,
read back byte-for-byte, replace, delete, second delete 404, foreign UID
403 with no store call, unconfigured 503.
This commit is contained in:
2026-09-04 17:56:55 -04:00
parent c458b8e64d
commit e52cc60fcf
4 changed files with 411 additions and 0 deletions
+54
View File
@@ -208,6 +208,60 @@ def _tok(req):
check("admin token from the LAN passes", _tok(_Req("10.0.0.55")) == 200)
check("admin token from outside is 403, regardless of the setting", _tok(_Req("108.36.248.87")) == 403)
print("\ncalendar write-back")
import calendar_write as C
raises("title required", 422, C.CalendarRejected, C.clean, {"date": "2026-10-03"})
raises("date required", 422, C.CalendarRejected, C.clean, {"title": "x"})
raises("bad unit", 422, C.CalendarRejected, C.clean, {"title": "x", "date": "2026-10-03", "unit": "den"})
raises("end before start", 422, C.CalendarRejected, C.clean, {"title": "x", "date": "2026-10-03", "end": "2026-10-02"})
raises("bad time", 422, C.CalendarRejected, C.clean, {"title": "x", "date": "2026-10-03", "time": "1pm"})
raises("end_time without time", 422, C.CalendarRejected, C.clean, {"title": "x", "date": "2026-10-03", "end_time": "14:00"})
raises("end_time before time same day", 422, C.CalendarRejected, C.clean, {"title": "x", "date": "2026-10-03", "time": "14:00", "end_time": "13:00"})
ev = C.clean({"title": "Pack Hike; again", "unit": "Pack", "date": "2026-10-03", "end": "2026-10-03", "time": "13:00",
"location": "Foy Park", "badge": "", "description": "Bring water,\nand a hat"})
check("clean normalises: unit lower, same-day end dropped, blank badge absent",
ev["unit"] == "pack" and ev["end"] is None and ev["badge"] is None and ev["time"] == "13:00")
uid = "t1" + C.UID_SUFFIX
ics = C.build_ics(uid, ev, stamp="20260904T000000Z").decode()
check("timed event carries VTIMEZONE and TZID start, 90-minute default end",
"BEGIN:VTIMEZONE" in ics and "DTSTART;TZID=America/New_York:20261003T130000" in ics
and "DTEND;TZID=America/New_York:20261003T143000" in ics)
check("text is escaped and newlines encoded", "SUMMARY:Pack Hike\\; again" in ics and "DESCRIPTION:Bring water\\,\\nand a hat" in ics)
check("unit is CATEGORIES, crlf line ends", "CATEGORIES:pack" in ics and ics.endswith("END:VCALENDAR\r\n")
and ics.count("\n") == ics.count("\r\n"))
allday = C.build_ics(uid, C.clean({"title": "Fall Campout", "unit": "both", "date": "2026-10-17", "end": "2026-10-18", "badge": "OVERNIGHT"}), stamp="20260904T000000Z").decode()
check("all-day multi-day: VALUE=DATE with exclusive end, badge, no VTIMEZONE",
"DTSTART;VALUE=DATE:20261017" in allday and "DTEND;VALUE=DATE:20261019" in allday
and "X-SCOUT73-BADGE:OVERNIGHT" in allday and "VTIMEZONE" not in allday)
multi = C.build_ics(uid, C.clean({"title": "x", "date": "2026-10-17", "end": "2026-10-18", "time": "16:00"}), stamp="20260904T000000Z").decode()
check("timed multi-day with no end_time ends at noon on the end date, as seed.py did",
"DTEND;TZID=America/New_York:20261018T120000" in multi)
longt = C.build_ics(uid, C.clean({"title": "A" * 120, "date": "2026-10-03"}), stamp="20260904T000000Z").decode()
check("long lines are folded at 75 octets", all(len(l.encode()) <= 75 for l in longt.split("\r\n")))
check("ownership by suffix", C.owns("x" + C.UID_SUFFIX) and not C.owns("site73-abc@greenlanescouts73.org")
and not C.owns("x@band.us") and not C.owns(None))
check("new uids are owned and unique", C.owns(C.new_uid()) and C.new_uid() != C.new_uid())
check("slug is stable and marked", C.slug("a" + C.UID_SUFFIX).startswith("site-") and C.slug("a" + C.UID_SUFFIX) == C.slug("a" + C.UID_SUFFIX))
# fail closed and never touch a foreign uid, with the transport watched
calls = []
class _Resp:
status = 201
def __enter__(self): return self
def __exit__(self, *a): return False
def _fake(req, timeout=None):
calls.append((req.get_method(), req.full_url, req.get_header("Authorization") is not None)); return _Resp()
C._urlopen = _fake
C.RADICALE_URL = ""; C.RADICALE_USER = ""; C.RADICALE_PASS = ""
raises("unconfigured put is 503", 503, C.CalendarRejected, C.put_event, uid, ev)
check("and nothing was sent", calls == [])
C.RADICALE_URL = "http://radicale.test/scouts/site73/"; C.RADICALE_USER = "scoutsite"; C.RADICALE_PASS = "p"
raises("foreign uid put is 403", 403, C.CalendarRejected, C.put_event, "site73-abc@greenlanescouts73.org", ev)
raises("foreign uid delete is 403", 403, C.CalendarRejected, C.delete_event, "x@band.us")
check("still nothing sent for foreign uids", calls == [])
check("owned put goes to the slug with auth", C.put_event(uid, ev) == 201 and calls[-1][0] == "PUT"
and calls[-1][1] == "http://radicale.test/scouts/site73/" + C.slug(uid) and calls[-1][2])
check("owned delete", C.delete_event(uid) == 201 and calls[-1][0] == "DELETE")
print("\napi docs registry")
import admin_api as A
reg = A.describe_routes()