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:
@@ -48,6 +48,7 @@ from fastapi import APIRouter, Body, Header, HTTPException, Query, Request
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
import auth
|
import auth
|
||||||
|
import calendar_write
|
||||||
import identity
|
import identity
|
||||||
import store
|
import store
|
||||||
|
|
||||||
@@ -498,3 +499,105 @@ def api_docs(request: Request, x_admin_token: str = Header(None)):
|
|||||||
"<table><tr><th>Method</th><th>Path</th><th>Needs</th><th>Query / path params</th><th>Notes</th></tr>%s</table>"
|
"<table><tr><th>Method</th><th>Path</th><th>Needs</th><th>Query / path params</th><th>Notes</th></tr>%s</table>"
|
||||||
"</div></body></html>" % (mine, rows))
|
"</div></body></html>" % (mine, rows))
|
||||||
return HTMLResponse(body)
|
return HTMLResponse(body)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Calendar write-back (P4). The site becomes a second writer to Radicale,
|
||||||
|
# owning only UIDs ending calendar_write.UID_SUFFIX. Reads come from the
|
||||||
|
# scout-calendar feed (fetched fresh here, not from the page cache), so the
|
||||||
|
# list is the same rows the public site renders plus uid and ownership.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _feed_rows():
|
||||||
|
import app as main_app
|
||||||
|
try:
|
||||||
|
rows = main_app._fetch_feed()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, "calendar feed unreachable: %s" % e)
|
||||||
|
for r in rows:
|
||||||
|
r["mine"] = calendar_write.owns(r.get("uid")) and not r.get("recurring")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/calendar")
|
||||||
|
def list_calendar(request: Request, x_admin_token: str = Header(None)):
|
||||||
|
"""Every event the public calendar shows, newest first is NOT the order:
|
||||||
|
the feed's own date order. `mine` marks rows the site created and may
|
||||||
|
edit or delete; everything else is read-only here and edited in a
|
||||||
|
CalDAV client. `configured` says whether writes are possible at all."""
|
||||||
|
_auth(request, x_admin_token, "calendar:write")
|
||||||
|
return {"events": _feed_rows(), "configured": calendar_write.configured(),
|
||||||
|
"uid_suffix": calendar_write.UID_SUFFIX}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/calendar", status_code=201)
|
||||||
|
def create_event(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
|
||||||
|
"""Add an event. Body: title, date (YYYY-MM-DD), unit (pack|troop|both),
|
||||||
|
optional end, time (HH:MM 24h), end_time, location, badge, description.
|
||||||
|
No time = all-day. A timed one-day event with no end_time lasts 90 min."""
|
||||||
|
actor = _auth(request, x_admin_token, "calendar:write")
|
||||||
|
try:
|
||||||
|
ev = calendar_write.clean(payload)
|
||||||
|
uid = calendar_write.new_uid()
|
||||||
|
calendar_write.put_event(uid, ev)
|
||||||
|
except calendar_write.CalendarRejected as e:
|
||||||
|
raise HTTPException(e.status, e.detail)
|
||||||
|
identity.log_event("calendar.created", email=actor if "@" in actor else None,
|
||||||
|
detail="%s %s %s" % (uid, ev["date"].isoformat(), ev["title"]))
|
||||||
|
_bust_site_cache()
|
||||||
|
return {"uid": uid, "event": _serial(ev)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/calendar/{uid}")
|
||||||
|
def replace_event(request: Request, uid: str, payload: dict = Body(...), x_admin_token: str = Header(None)):
|
||||||
|
"""Replace one site-owned event in full (same body as POST). A UID the
|
||||||
|
site does not own is 403 before anything is sent to the store."""
|
||||||
|
actor = _auth(request, x_admin_token, "calendar:write")
|
||||||
|
if not calendar_write.owns(uid):
|
||||||
|
raise HTTPException(403, "the site only manages events it created")
|
||||||
|
try:
|
||||||
|
ev = calendar_write.clean(payload)
|
||||||
|
calendar_write.put_event(uid, ev)
|
||||||
|
except calendar_write.CalendarRejected as e:
|
||||||
|
raise HTTPException(e.status, e.detail)
|
||||||
|
identity.log_event("calendar.updated", email=actor if "@" in actor else None,
|
||||||
|
detail="%s %s %s" % (uid, ev["date"].isoformat(), ev["title"]))
|
||||||
|
_bust_site_cache()
|
||||||
|
return {"uid": uid, "event": _serial(ev)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/calendar/{uid}")
|
||||||
|
def delete_event(request: Request, uid: str, x_admin_token: str = Header(None)):
|
||||||
|
"""Remove one site-owned event from the store. Unlike everything else on
|
||||||
|
this API this really deletes: the calendar's history is Radicale's git
|
||||||
|
log, not a revoked_at column."""
|
||||||
|
actor = _auth(request, x_admin_token, "calendar:write")
|
||||||
|
if not calendar_write.owns(uid):
|
||||||
|
raise HTTPException(403, "the site only manages events it created")
|
||||||
|
try:
|
||||||
|
status = calendar_write.delete_event(uid)
|
||||||
|
except calendar_write.CalendarRejected as e:
|
||||||
|
raise HTTPException(e.status, e.detail)
|
||||||
|
if status == 404:
|
||||||
|
raise HTTPException(404, "no such event in the store")
|
||||||
|
identity.log_event("calendar.deleted", email=actor if "@" in actor else None, detail=uid)
|
||||||
|
_bust_site_cache()
|
||||||
|
return {"uid": uid, "deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _serial(ev):
|
||||||
|
out = dict(ev)
|
||||||
|
out["date"] = ev["date"].isoformat()
|
||||||
|
out["end"] = ev["end"].isoformat() if ev["end"] else None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _bust_site_cache():
|
||||||
|
"""The public pages cache the feed for five minutes; a leader who just
|
||||||
|
posted an event should not wait that long to see it. The feed itself
|
||||||
|
refreshes within a minute."""
|
||||||
|
try:
|
||||||
|
import app as main_app
|
||||||
|
main_app._feed_state["fetched"] = 0.0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""
|
||||||
|
calendar_write.py - the site's write-back to the public calendar (P4).
|
||||||
|
|
||||||
|
Radicale collection scouts/site73 is the source of truth for the public
|
||||||
|
calendar; the scout-calendar feed converts it to the JSON the site reads.
|
||||||
|
This module is the one place the site WRITES to that collection, over
|
||||||
|
CalDAV, as the scoped `scoutsite` principal. Two rules, both enforced here
|
||||||
|
and not left to callers:
|
||||||
|
|
||||||
|
1. The site owns only UIDs ending UID_SUFFIX and touches nothing else.
|
||||||
|
band-cal-sync owns @band.us the same way; the seeded events and anything
|
||||||
|
Mike adds in a CalDAV client stay outside the site's reach. A wrong
|
||||||
|
UID is a 403 before any network call.
|
||||||
|
2. Fail closed. With no RADICALE_* configuration every write is a 503,
|
||||||
|
never a silent no-op that reads as success.
|
||||||
|
|
||||||
|
The VEVENT layout matches projects/scout-calendar/seed.py exactly (all-day
|
||||||
|
DTEND exclusive, TZID + VTIMEZONE on timed events, CATEGORIES for the unit,
|
||||||
|
X-SCOUT73-BADGE, 75-octet folding) so the feed reverses it into the same
|
||||||
|
row shape the site already renders. Stdlib only, like the rest of the app.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
UID_SUFFIX = "@site73.greenlanescouts73.org"
|
||||||
|
TZID = "America/New_York"
|
||||||
|
UNITS = ("pack", "troop", "both")
|
||||||
|
DEFAULT_TIMED_MINUTES = 90
|
||||||
|
TIMEOUT = 8
|
||||||
|
|
||||||
|
RADICALE_URL = (os.environ.get("RADICALE_URL") or "").strip()
|
||||||
|
RADICALE_USER = (os.environ.get("RADICALE_USER") or "").strip()
|
||||||
|
RADICALE_PASS = os.environ.get("RADICALE_PASS") or ""
|
||||||
|
|
||||||
|
VTIMEZONE_NY = """BEGIN:VTIMEZONE
|
||||||
|
TZID:America/New_York
|
||||||
|
X-LIC-LOCATION:America/New_York
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETFROM:-0500
|
||||||
|
TZOFFSETTO:-0400
|
||||||
|
TZNAME:EDT
|
||||||
|
DTSTART:19700308T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETFROM:-0400
|
||||||
|
TZOFFSETTO:-0500
|
||||||
|
TZNAME:EST
|
||||||
|
DTSTART:19701101T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE""".split("\n")
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarRejected(Exception):
|
||||||
|
def __init__(self, status, detail):
|
||||||
|
super().__init__(detail)
|
||||||
|
self.status = status
|
||||||
|
self.detail = detail
|
||||||
|
|
||||||
|
|
||||||
|
def configured():
|
||||||
|
return bool(RADICALE_URL and RADICALE_USER and RADICALE_PASS)
|
||||||
|
|
||||||
|
|
||||||
|
def owns(uid):
|
||||||
|
return bool(uid) and str(uid).endswith(UID_SUFFIX)
|
||||||
|
|
||||||
|
|
||||||
|
def new_uid():
|
||||||
|
return str(uuid.uuid4()) + UID_SUFFIX
|
||||||
|
|
||||||
|
|
||||||
|
def slug(uid):
|
||||||
|
"""Stable object name per UID. The site- prefix marks ownership on disk
|
||||||
|
the way band- does for the BAND mirror."""
|
||||||
|
return "site-" + hashlib.sha1(uid.encode("utf-8")).hexdigest()[:16] + ".ics"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Building the object
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def esc(v):
|
||||||
|
return (str(v).replace("\\", "\\\\").replace(";", "\\;")
|
||||||
|
.replace(",", "\\,").replace("\n", "\\n"))
|
||||||
|
|
||||||
|
|
||||||
|
def fold(line):
|
||||||
|
"""RFC 5545 folding at 75 OCTETS."""
|
||||||
|
enc = line.encode("utf-8")
|
||||||
|
if len(enc) <= 75:
|
||||||
|
return [line]
|
||||||
|
out, cur, size = [], "", 0
|
||||||
|
for ch in line:
|
||||||
|
w = len(ch.encode("utf-8"))
|
||||||
|
if size + w > 75:
|
||||||
|
out.append(cur)
|
||||||
|
cur, size = " " + ch, 1 + w
|
||||||
|
else:
|
||||||
|
cur += ch
|
||||||
|
size += w
|
||||||
|
if cur:
|
||||||
|
out.append(cur)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _date(v, what):
|
||||||
|
try:
|
||||||
|
return datetime.date.fromisoformat(str(v).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise CalendarRejected(422, "%s must be a YYYY-MM-DD date" % what)
|
||||||
|
|
||||||
|
|
||||||
|
def _clock(v, what):
|
||||||
|
try:
|
||||||
|
t = datetime.datetime.strptime(str(v).strip(), "%H:%M")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise CalendarRejected(422, '%s must be 24h "HH:MM"' % what)
|
||||||
|
return t.hour, t.minute
|
||||||
|
|
||||||
|
|
||||||
|
def clean(fields):
|
||||||
|
"""Validate and normalise an event payload. Returns the dict the builder
|
||||||
|
uses. Blank strings are treated as absent."""
|
||||||
|
f = {k: (v.strip() if isinstance(v, str) else v) for k, v in (fields or {}).items()}
|
||||||
|
f = {k: (None if v == "" else v) for k, v in f.items()}
|
||||||
|
title = f.get("title")
|
||||||
|
if not title:
|
||||||
|
raise CalendarRejected(422, "title is required")
|
||||||
|
if len(title) > 120:
|
||||||
|
raise CalendarRejected(422, "title is over 120 characters")
|
||||||
|
unit = (f.get("unit") or "pack").lower()
|
||||||
|
if unit not in UNITS:
|
||||||
|
raise CalendarRejected(422, "unit must be one of %s" % (UNITS,))
|
||||||
|
start = _date(f.get("date"), "date")
|
||||||
|
end = _date(f["end"], "end") if f.get("end") else None
|
||||||
|
if end is not None and end < start:
|
||||||
|
raise CalendarRejected(422, "end must be on or after date")
|
||||||
|
if end == start:
|
||||||
|
end = None
|
||||||
|
time_ = f.get("time")
|
||||||
|
end_time = f.get("end_time")
|
||||||
|
if end_time and not time_:
|
||||||
|
raise CalendarRejected(422, "end_time needs a start time")
|
||||||
|
if time_:
|
||||||
|
h, m = _clock(time_, "time")
|
||||||
|
time_ = "%02d:%02d" % (h, m)
|
||||||
|
if end_time:
|
||||||
|
eh, em = _clock(end_time, "end_time")
|
||||||
|
end_time = "%02d:%02d" % (eh, em)
|
||||||
|
if end is None and (eh, em) <= (h, m):
|
||||||
|
raise CalendarRejected(422, "end_time must be after time on a one-day event")
|
||||||
|
for k, cap in (("location", 160), ("badge", 40), ("description", 2000)):
|
||||||
|
if f.get(k) and len(str(f[k])) > cap:
|
||||||
|
raise CalendarRejected(422, "%s is over %d characters" % (k, cap))
|
||||||
|
return {"title": title, "unit": unit, "date": start, "end": end, "time": time_,
|
||||||
|
"end_time": end_time, "location": f.get("location"), "badge": f.get("badge"),
|
||||||
|
"description": f.get("description")}
|
||||||
|
|
||||||
|
|
||||||
|
def build_ics(uid, ev, stamp=None):
|
||||||
|
"""The complete text/calendar object for one event. `ev` is clean()'s
|
||||||
|
output. Returns bytes with CRLF line ends."""
|
||||||
|
stamp = stamp or datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
start, end = ev["date"], ev["end"]
|
||||||
|
lines = ["BEGIN:VEVENT", "UID:%s" % uid, "DTSTAMP:%s" % stamp, "SUMMARY:%s" % esc(ev["title"])]
|
||||||
|
timed = bool(ev["time"])
|
||||||
|
if timed:
|
||||||
|
h, m = _clock(ev["time"], "time")
|
||||||
|
lines.append("DTSTART;TZID=%s:%s" % (TZID, "%sT%02d%02d00" % (start.strftime("%Y%m%d"), h, m)))
|
||||||
|
if end is not None:
|
||||||
|
if ev["end_time"]:
|
||||||
|
eh, em = _clock(ev["end_time"], "end_time")
|
||||||
|
lines.append("DTEND;TZID=%s:%sT%02d%02d00" % (TZID, end.strftime("%Y%m%d"), eh, em))
|
||||||
|
else:
|
||||||
|
lines.append("DTEND;TZID=%s:%sT120000" % (TZID, end.strftime("%Y%m%d")))
|
||||||
|
else:
|
||||||
|
if ev["end_time"]:
|
||||||
|
eh, em = _clock(ev["end_time"], "end_time")
|
||||||
|
dt_end = datetime.datetime.combine(start, datetime.time(eh, em))
|
||||||
|
else:
|
||||||
|
dt_end = (datetime.datetime.combine(start, datetime.time(h, m))
|
||||||
|
+ datetime.timedelta(minutes=DEFAULT_TIMED_MINUTES))
|
||||||
|
lines.append("DTEND;TZID=%s:%s" % (TZID, dt_end.strftime("%Y%m%dT%H%M00")))
|
||||||
|
else:
|
||||||
|
lines.append("DTSTART;VALUE=DATE:%s" % start.strftime("%Y%m%d"))
|
||||||
|
last = end or start
|
||||||
|
lines.append("DTEND;VALUE=DATE:%s" % (last + datetime.timedelta(days=1)).strftime("%Y%m%d"))
|
||||||
|
if ev.get("location"):
|
||||||
|
lines.append("LOCATION:%s" % esc(ev["location"]))
|
||||||
|
if ev.get("description"):
|
||||||
|
lines.append("DESCRIPTION:%s" % esc(ev["description"]))
|
||||||
|
lines.append("CATEGORIES:%s" % esc(ev["unit"]))
|
||||||
|
if ev.get("badge"):
|
||||||
|
lines.append("X-SCOUT73-BADGE:%s" % esc(ev["badge"]))
|
||||||
|
lines.append("END:VEVENT")
|
||||||
|
body = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//greenlanescouts73.org//site-calendar-write//EN"]
|
||||||
|
if timed:
|
||||||
|
body += VTIMEZONE_NY
|
||||||
|
body += lines + ["END:VCALENDAR"]
|
||||||
|
out = []
|
||||||
|
for ln in body:
|
||||||
|
out.extend(fold(ln))
|
||||||
|
return ("\r\n".join(out) + "\r\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CalDAV
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _request(method, uid, data=None):
|
||||||
|
if not configured():
|
||||||
|
raise CalendarRejected(503, "calendar write-back is not configured (RADICALE_URL/USER/PASS)")
|
||||||
|
if not owns(uid):
|
||||||
|
raise CalendarRejected(403, "the site only manages events it created (UIDs ending %s)" % UID_SUFFIX)
|
||||||
|
url = RADICALE_URL.rstrip("/") + "/" + slug(uid)
|
||||||
|
req = urllib.request.Request(url, data=data, method=method)
|
||||||
|
req.add_header("Authorization", "Basic " + base64.b64encode(
|
||||||
|
("%s:%s" % (RADICALE_USER, RADICALE_PASS)).encode()).decode())
|
||||||
|
if data is not None:
|
||||||
|
req.add_header("Content-Type", "text/calendar; charset=utf-8")
|
||||||
|
try:
|
||||||
|
with _urlopen(req, timeout=TIMEOUT) as resp:
|
||||||
|
return resp.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if method == "DELETE" and e.code == 404:
|
||||||
|
return 404
|
||||||
|
raise CalendarRejected(502, "calendar store answered %d on %s" % (e.code, method))
|
||||||
|
except Exception as e:
|
||||||
|
raise CalendarRejected(502, "calendar store unreachable: %s" % e)
|
||||||
|
|
||||||
|
|
||||||
|
# Seam for tests.
|
||||||
|
_urlopen = urllib.request.urlopen
|
||||||
|
|
||||||
|
|
||||||
|
def put_event(uid, ev):
|
||||||
|
return _request("PUT", uid, build_ics(uid, ev))
|
||||||
|
|
||||||
|
|
||||||
|
def delete_event(uid):
|
||||||
|
return _request("DELETE", uid)
|
||||||
@@ -46,6 +46,11 @@ services:
|
|||||||
# forwarded here; adding it to the stack alone is not enough.
|
# forwarded here; adding it to the stack alone is not enough.
|
||||||
- ADMIN_BOOTSTRAP_EMAIL=${ADMIN_BOOTSTRAP_EMAIL}
|
- ADMIN_BOOTSTRAP_EMAIL=${ADMIN_BOOTSTRAP_EMAIL}
|
||||||
- SITE_BASE_URL=${SITE_BASE_URL}
|
- SITE_BASE_URL=${SITE_BASE_URL}
|
||||||
|
# P4 calendar write-back. The scoped `scoutsite` Radicale principal, rw
|
||||||
|
# on scouts/site73 only. Unset = every calendar write is a 503.
|
||||||
|
- RADICALE_URL=${RADICALE_URL}
|
||||||
|
- RADICALE_USER=${RADICALE_USER}
|
||||||
|
- RADICALE_PASS=${RADICALE_PASS}
|
||||||
volumes:
|
volumes:
|
||||||
- /srv/scout-website/data:/data
|
- /srv/scout-website/data:/data
|
||||||
- /srv/scout-website/secrets/service_account.json:/app/service_account.json:ro
|
- /srv/scout-website/secrets/service_account.json:/app/service_account.json:ro
|
||||||
|
|||||||
@@ -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 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)
|
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")
|
print("\napi docs registry")
|
||||||
import admin_api as A
|
import admin_api as A
|
||||||
reg = A.describe_routes()
|
reg = A.describe_routes()
|
||||||
|
|||||||
Reference in New Issue
Block a user