seeded events are the site's; every write lands in the action log

calendar_write.owns() now accepts both site namespaces: the 32 events the
2026-08-29 seed stamped site73-<sha1>@greenlanescouts73.org and the site's
own @site73.greenlanescouts73.org. The rule exists to keep the site off
@band.us and off anything a person adds in a calendar client, not off its
own data. Seeded objects are written back at the seeder's object name so
an edit replaces in place; proven on a throwaway against the real store
(edit, still 32 objects, restored byte-for-byte).

auth_events becomes the one action log. Every P2 write - nearby create,
update, deactivate; unit meeting edit; setting change; announcement post
and take-down - now records who, when, and a one-line before -> after for
the fields that changed, alongside the login, key and calendar entries
already there. GET /api/admin/history (history:read, admin and above, not
scopable on a key) reads it newest first with a kind prefix filter and
before= paging; rowid breaks second-resolution ties.

tests/smoke_admin.py 102 -> 112.
This commit is contained in:
2026-09-04 18:10:03 -04:00
parent e52cc60fcf
commit 7287f0b515
4 changed files with 160 additions and 23 deletions
+41 -2
View File
@@ -238,7 +238,7 @@ check("timed multi-day with no end_time ends at noon on the end date, as seed.py
"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")
check("ownership by suffix", C.owns("x" + C.UID_SUFFIX) and not C.owns("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))
@@ -255,13 +255,52 @@ 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 put is 403", 403, C.CalendarRejected, C.put_event, "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("\nseeded events are the site's too")
check("both namespaces owned, nothing else", C.owns("x" + C.UID_SUFFIX) and C.owns("site73-abc123@greenlanescouts73.org")
and not C.owns("abc@greenlanescouts73.org") and not C.owns("x@band.us") and not C.owns("site73-x@site73.example"))
check("object name per namespace", C.object_name("a" + C.UID_SUFFIX) == C.slug("a" + C.UID_SUFFIX)
and C.object_name("site73-abc@greenlanescouts73.org") == "site73-abc%40greenlanescouts73.org.ics")
calls.clear(); C.put_event("site73-abc@greenlanescouts73.org", ev)
check("seeded put goes to the seeder's object name", calls[-1][1].endswith("/site73-abc%40greenlanescouts73.org.ics"))
print("\naction log")
class _Sess:
def __init__(self, tok):
self.headers = {"x-forwarded-for": "10.0.0.5"}; self.cookies = {"s73_session": tok}; self.client = None
req = _Sess(I.start_session(pid))
n0 = len(I.list_events(limit=500))
A.update_unit(req, "pack73", {"meets_time": "18:15"}, None)
A.update_unit(req, "pack73", {"meets_time": "18:00"}, None)
A.put_setting(req, "nearby_source_name", {"value": "Test source"}, None)
A.put_setting(req, "nearby_source_name", {"value": None}, None)
nb = A.create_nearby(req, {"unit_type": "pack", "unit_number": "ZZ1", "town": "A"}, None)
A.update_nearby(req, nb["id"], {"town": "B"}, None)
A.deactivate_nearby(req, nb["id"], None)
an = A.create_announcement(req, {"message": "log me", "ends_at": "2036-01-02T00:00:00+00:00"}, None)
A.revoke_announcement(req, an["id"], None)
ev_rows = I.list_events(limit=500)
kinds = [e["kind"] for e in ev_rows[:len(ev_rows) - n0]]
check("every P2 write lands in the log", set(kinds) >= {"unit.updated", "setting.updated", "nearby.created",
"nearby.updated", "nearby.deactivated", "announcement.created", "announcement.revoked"})
check("attributed to the person", all(e["actor_email"] == "lanrule@example.test" for e in ev_rows[:len(ev_rows) - n0]))
unit_ev = [e for e in ev_rows if e["kind"] == "unit.updated"][-1]
check("diff is before -> after", "-> '18:15'" in unit_ev["detail"] and "meets_time:" in unit_ev["detail"])
nb_ev = [e for e in ev_rows if e["kind"] == "nearby.updated"][0]
check("nearby diff names the field", "town: 'A' -> 'B'" in nb_ev["detail"])
set_ev = [e for e in ev_rows if e["kind"] == "setting.updated"][0]
check("clearing a setting is said so", "cleared to default" in set_ev["detail"])
check("kind prefix filter and limit", all(e["kind"].startswith("nearby.") for e in I.list_events(kind_prefix="nearby."))
and len(I.list_events(limit=2)) == 2)
check("history is admin and above, and not scopable on a key", "history:read" in I.CAPS["admin"]
and "history:read" not in I.CAPS["leader"] and "history:read" in I.KEY_UNSCOPABLE)
print("\napi docs registry")
import admin_api as A
reg = A.describe_routes()