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:
+67
-16
@@ -82,6 +82,26 @@ def client_is_lan(request):
|
|||||||
return any(ip in n for n in LAN_NETS)
|
return any(ip in n for n in LAN_NETS)
|
||||||
|
|
||||||
|
|
||||||
|
def _log(request, kind, detail):
|
||||||
|
"""One line in the action log for a write, attributed to whoever the
|
||||||
|
session or key resolved to; the break-glass token logs as itself."""
|
||||||
|
person = _person(request)
|
||||||
|
identity.log_event(kind, person_id=person["id"] if person else None,
|
||||||
|
actor_id=person["id"] if person else None,
|
||||||
|
email=person["email"] if person else "admin-token",
|
||||||
|
detail=detail, ip=auth._client_ip(request))
|
||||||
|
|
||||||
|
|
||||||
|
def _diff(before, after, fields):
|
||||||
|
"""'town: "X" -> "Y"; meets: "-" -> "Mondays"' for the fields that changed."""
|
||||||
|
out = []
|
||||||
|
for f in fields:
|
||||||
|
a, b = (before or {}).get(f), (after or {}).get(f)
|
||||||
|
if a != b:
|
||||||
|
out.append('%s: %r -> %r' % (f, a if a not in (None, "") else "-", b if b not in (None, "") else "-"))
|
||||||
|
return "; ".join(out) or "no change"
|
||||||
|
|
||||||
|
|
||||||
def _person(request):
|
def _person(request):
|
||||||
"""Who is calling: a session first, then an API key, then nobody.
|
"""Who is calling: a session first, then an API key, then nobody.
|
||||||
|
|
||||||
@@ -214,7 +234,7 @@ def create_announcement(request: Request, payload: dict = Body(...), x_admin_tok
|
|||||||
decide what to revoke."""
|
decide what to revoke."""
|
||||||
_actor = _auth(request, x_admin_token, "announcements:write")
|
_actor = _auth(request, x_admin_token, "announcements:write")
|
||||||
try:
|
try:
|
||||||
return store.create_announcement(
|
rec = store.create_announcement(
|
||||||
message=payload.get("message"),
|
message=payload.get("message"),
|
||||||
ends_at=payload.get("ends_at"),
|
ends_at=payload.get("ends_at"),
|
||||||
starts_at=payload.get("starts_at"),
|
starts_at=payload.get("starts_at"),
|
||||||
@@ -230,6 +250,8 @@ def create_announcement(request: Request, payload: dict = Body(...), x_admin_tok
|
|||||||
)
|
)
|
||||||
except store.AnnouncementRejected as e:
|
except store.AnnouncementRejected as e:
|
||||||
raise _reject(e)
|
raise _reject(e)
|
||||||
|
_log(request, "announcement.created", "%s %r" % (rec["id"], (rec.get("message") or "")[:60]))
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/announcements/{announcement_id}")
|
@router.delete("/announcements/{announcement_id}")
|
||||||
@@ -240,7 +262,9 @@ def revoke_announcement(request: Request, announcement_id: str, x_admin_token: s
|
|||||||
raise HTTPException(404, "no such announcement")
|
raise HTTPException(404, "no such announcement")
|
||||||
if not store.revoke_announcement(announcement_id):
|
if not store.revoke_announcement(announcement_id):
|
||||||
raise HTTPException(409, "already revoked")
|
raise HTTPException(409, "already revoked")
|
||||||
return store.get_announcement(announcement_id)
|
rec = store.get_announcement(announcement_id)
|
||||||
|
_log(request, "announcement.revoked", "%s %r" % (announcement_id, (rec.get("message") or "")[:60]))
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
@@ -264,9 +288,11 @@ def create_nearby(request: Request, payload: dict = Body(...),
|
|||||||
x_admin_token: str = Header(None)):
|
x_admin_token: str = Header(None)):
|
||||||
_auth(request, x_admin_token, "nearby:write")
|
_auth(request, x_admin_token, "nearby:write")
|
||||||
try:
|
try:
|
||||||
return store.create_nearby(payload)
|
rec = store.create_nearby(payload)
|
||||||
except store.NearbyRejected as e:
|
except store.NearbyRejected as e:
|
||||||
raise _reject(e)
|
raise _reject(e)
|
||||||
|
_log(request, "nearby.created", "%s %s %s (%s)" % (rec["id"], rec.get("unit_type"), rec.get("unit_number"), rec.get("town")))
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/nearby/{nearby_id}")
|
@router.patch("/nearby/{nearby_id}")
|
||||||
@@ -275,12 +301,15 @@ def update_nearby(request: Request, nearby_id: str, payload: dict = Body(...),
|
|||||||
"""Partial update. Saving bumps verified_at to today unless the payload
|
"""Partial update. Saving bumps verified_at to today unless the payload
|
||||||
carries an explicit date - see store.py for why saving is verifying."""
|
carries an explicit date - see store.py for why saving is verifying."""
|
||||||
_auth(request, x_admin_token, "nearby:write")
|
_auth(request, x_admin_token, "nearby:write")
|
||||||
|
before = store.get_nearby(nearby_id)
|
||||||
try:
|
try:
|
||||||
rec = store.update_nearby(nearby_id, payload)
|
rec = store.update_nearby(nearby_id, payload)
|
||||||
except store.NearbyRejected as e:
|
except store.NearbyRejected as e:
|
||||||
raise _reject(e)
|
raise _reject(e)
|
||||||
if not rec:
|
if not rec:
|
||||||
raise HTTPException(404, "no such nearby unit")
|
raise HTTPException(404, "no such nearby unit")
|
||||||
|
_log(request, "nearby.updated", "%s %s %s: %s" % (nearby_id, rec.get("unit_type"), rec.get("unit_number"),
|
||||||
|
_diff(before, rec, [k for k in store.NEARBY_FIELDS if k != "verified_at"])))
|
||||||
return rec
|
return rec
|
||||||
|
|
||||||
|
|
||||||
@@ -292,7 +321,9 @@ def deactivate_nearby(request: Request, nearby_id: str, x_admin_token: str = Hea
|
|||||||
raise HTTPException(404, "no such nearby unit")
|
raise HTTPException(404, "no such nearby unit")
|
||||||
if not store.deactivate_nearby(nearby_id):
|
if not store.deactivate_nearby(nearby_id):
|
||||||
raise HTTPException(409, "already inactive")
|
raise HTTPException(409, "already inactive")
|
||||||
return store.get_nearby(nearby_id)
|
rec = store.get_nearby(nearby_id)
|
||||||
|
_log(request, "nearby.deactivated", "%s %s %s" % (nearby_id, rec.get("unit_type"), rec.get("unit_number")))
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
@@ -324,9 +355,11 @@ def update_unit(request: Request, slug_or_id: str, payload: dict = Body(...),
|
|||||||
_auth(request, x_admin_token, "unit:write_own",
|
_auth(request, x_admin_token, "unit:write_own",
|
||||||
unit_id=unit["id"] if unit else None)
|
unit_id=unit["id"] if unit else None)
|
||||||
try:
|
try:
|
||||||
return identity.update_unit_meets(slug_or_id, payload)
|
rec = identity.update_unit_meets(slug_or_id, payload)
|
||||||
except identity.IdentityError as e:
|
except identity.IdentityError as e:
|
||||||
raise HTTPException(e.status, e.detail)
|
raise HTTPException(e.status, e.detail)
|
||||||
|
_log(request, "unit.updated", "%s: %s" % (rec.get("slug"), _diff(unit, rec, list(identity.UNIT_MEETS_FIELDS))))
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
@@ -345,10 +378,15 @@ def put_setting(request: Request, key: str, payload: dict = Body(...),
|
|||||||
x_admin_token: str = Header(None)):
|
x_admin_token: str = Header(None)):
|
||||||
"""Set one value, or clear it back to the code default with value: null."""
|
"""Set one value, or clear it back to the code default with value: null."""
|
||||||
actor = _auth(request, x_admin_token, "settings:write")
|
actor = _auth(request, x_admin_token, "settings:write")
|
||||||
|
before = identity.get_setting(key) if key in identity.SETTINGS_KEYS else None
|
||||||
try:
|
try:
|
||||||
return identity.set_setting(key, payload.get("value"), actor=actor)
|
rec = identity.set_setting(key, payload.get("value"), actor=actor)
|
||||||
except identity.IdentityError as e:
|
except identity.IdentityError as e:
|
||||||
raise HTTPException(e.status, e.detail)
|
raise HTTPException(e.status, e.detail)
|
||||||
|
after = identity.get_setting(key)
|
||||||
|
_log(request, "setting.updated", "%s: %r -> %r%s" % (key, before, after,
|
||||||
|
" (cleared to default)" if payload.get("value") is None else ""))
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
@@ -535,15 +573,14 @@ def create_event(request: Request, payload: dict = Body(...), x_admin_token: str
|
|||||||
"""Add an event. Body: title, date (YYYY-MM-DD), unit (pack|troop|both),
|
"""Add an event. Body: title, date (YYYY-MM-DD), unit (pack|troop|both),
|
||||||
optional end, time (HH:MM 24h), end_time, location, badge, description.
|
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."""
|
No time = all-day. A timed one-day event with no end_time lasts 90 min."""
|
||||||
actor = _auth(request, x_admin_token, "calendar:write")
|
_auth(request, x_admin_token, "calendar:write")
|
||||||
try:
|
try:
|
||||||
ev = calendar_write.clean(payload)
|
ev = calendar_write.clean(payload)
|
||||||
uid = calendar_write.new_uid()
|
uid = calendar_write.new_uid()
|
||||||
calendar_write.put_event(uid, ev)
|
calendar_write.put_event(uid, ev)
|
||||||
except calendar_write.CalendarRejected as e:
|
except calendar_write.CalendarRejected as e:
|
||||||
raise HTTPException(e.status, e.detail)
|
raise HTTPException(e.status, e.detail)
|
||||||
identity.log_event("calendar.created", email=actor if "@" in actor else None,
|
_log(request, "calendar.created", "%s %s %s" % (uid, ev["date"].isoformat(), ev["title"]))
|
||||||
detail="%s %s %s" % (uid, ev["date"].isoformat(), ev["title"]))
|
|
||||||
_bust_site_cache()
|
_bust_site_cache()
|
||||||
return {"uid": uid, "event": _serial(ev)}
|
return {"uid": uid, "event": _serial(ev)}
|
||||||
|
|
||||||
@@ -552,16 +589,15 @@ def create_event(request: Request, payload: dict = Body(...), x_admin_token: str
|
|||||||
def replace_event(request: Request, uid: str, payload: dict = Body(...), x_admin_token: str = Header(None)):
|
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
|
"""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."""
|
site does not own is 403 before anything is sent to the store."""
|
||||||
actor = _auth(request, x_admin_token, "calendar:write")
|
_auth(request, x_admin_token, "calendar:write")
|
||||||
if not calendar_write.owns(uid):
|
if not calendar_write.owns(uid):
|
||||||
raise HTTPException(403, "the site only manages events it created")
|
raise HTTPException(403, "the site only manages its own events")
|
||||||
try:
|
try:
|
||||||
ev = calendar_write.clean(payload)
|
ev = calendar_write.clean(payload)
|
||||||
calendar_write.put_event(uid, ev)
|
calendar_write.put_event(uid, ev)
|
||||||
except calendar_write.CalendarRejected as e:
|
except calendar_write.CalendarRejected as e:
|
||||||
raise HTTPException(e.status, e.detail)
|
raise HTTPException(e.status, e.detail)
|
||||||
identity.log_event("calendar.updated", email=actor if "@" in actor else None,
|
_log(request, "calendar.updated", "%s %s %s" % (uid, ev["date"].isoformat(), ev["title"]))
|
||||||
detail="%s %s %s" % (uid, ev["date"].isoformat(), ev["title"]))
|
|
||||||
_bust_site_cache()
|
_bust_site_cache()
|
||||||
return {"uid": uid, "event": _serial(ev)}
|
return {"uid": uid, "event": _serial(ev)}
|
||||||
|
|
||||||
@@ -571,16 +607,16 @@ 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
|
"""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
|
this API this really deletes: the calendar's history is Radicale's git
|
||||||
log, not a revoked_at column."""
|
log, not a revoked_at column."""
|
||||||
actor = _auth(request, x_admin_token, "calendar:write")
|
_auth(request, x_admin_token, "calendar:write")
|
||||||
if not calendar_write.owns(uid):
|
if not calendar_write.owns(uid):
|
||||||
raise HTTPException(403, "the site only manages events it created")
|
raise HTTPException(403, "the site only manages its own events")
|
||||||
try:
|
try:
|
||||||
status = calendar_write.delete_event(uid)
|
status = calendar_write.delete_event(uid)
|
||||||
except calendar_write.CalendarRejected as e:
|
except calendar_write.CalendarRejected as e:
|
||||||
raise HTTPException(e.status, e.detail)
|
raise HTTPException(e.status, e.detail)
|
||||||
if status == 404:
|
if status == 404:
|
||||||
raise HTTPException(404, "no such event in the store")
|
raise HTTPException(404, "no such event in the store")
|
||||||
identity.log_event("calendar.deleted", email=actor if "@" in actor else None, detail=uid)
|
_log(request, "calendar.deleted", uid)
|
||||||
_bust_site_cache()
|
_bust_site_cache()
|
||||||
return {"uid": uid, "deleted": True}
|
return {"uid": uid, "deleted": True}
|
||||||
|
|
||||||
@@ -601,3 +637,18 @@ def _bust_site_cache():
|
|||||||
main_app._feed_state["fetched"] = 0.0
|
main_app._feed_state["fetched"] = 0.0
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# History - the action log, read side. Admin and above: it carries emails
|
||||||
|
# and IPs from logins alongside the content writes.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
def get_history(request: Request, limit: int = Query(200, ge=1, le=500), kind: str = None,
|
||||||
|
before: str = None, x_admin_token: str = Header(None)):
|
||||||
|
"""Newest first. `kind` is a prefix filter (calendar., nearby., login.).
|
||||||
|
`before` is an ISO stamp for paging. Every write on this API since
|
||||||
|
2026-09-04 lands here with who did it and a one-line diff."""
|
||||||
|
_auth(request, x_admin_token, "history:read")
|
||||||
|
return {"events": identity.list_events(limit=limit, kind_prefix=kind, before=before)}
|
||||||
|
|||||||
+22
-3
@@ -25,10 +25,16 @@ import datetime
|
|||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
UID_SUFFIX = "@site73.greenlanescouts73.org"
|
UID_SUFFIX = "@site73.greenlanescouts73.org"
|
||||||
|
# The 2026-08-29 seed stamped its 32 events site73-<sha1>@greenlanescouts73.org
|
||||||
|
# before this namespace existed. They are the site's own data too; the rule
|
||||||
|
# exists to keep the site off @band.us and off anything a person adds in a
|
||||||
|
# calendar client, not off itself.
|
||||||
|
SEED_PREFIX, SEED_SUFFIX = "site73-", "@greenlanescouts73.org"
|
||||||
TZID = "America/New_York"
|
TZID = "America/New_York"
|
||||||
UNITS = ("pack", "troop", "both")
|
UNITS = ("pack", "troop", "both")
|
||||||
DEFAULT_TIMED_MINUTES = 90
|
DEFAULT_TIMED_MINUTES = 90
|
||||||
@@ -70,7 +76,19 @@ def configured():
|
|||||||
|
|
||||||
|
|
||||||
def owns(uid):
|
def owns(uid):
|
||||||
return bool(uid) and str(uid).endswith(UID_SUFFIX)
|
u = str(uid or "")
|
||||||
|
return bool(u) and (u.endswith(UID_SUFFIX) or (u.startswith(SEED_PREFIX) and u.endswith(SEED_SUFFIX)))
|
||||||
|
|
||||||
|
|
||||||
|
def object_name(uid):
|
||||||
|
"""The CalDAV object the UID lives in. Seeded events sit at <uid>.ics as
|
||||||
|
the seeder wrote them; the site's own at a hashed site-*.ics. Getting
|
||||||
|
this wrong would create a second object with the same UID, which Radicale
|
||||||
|
refuses, so the two forms are tested separately."""
|
||||||
|
u = str(uid)
|
||||||
|
if u.endswith(UID_SUFFIX):
|
||||||
|
return slug(u)
|
||||||
|
return urllib.parse.quote(u + ".ics", safe="")
|
||||||
|
|
||||||
|
|
||||||
def new_uid():
|
def new_uid():
|
||||||
@@ -219,8 +237,9 @@ def _request(method, uid, data=None):
|
|||||||
if not configured():
|
if not configured():
|
||||||
raise CalendarRejected(503, "calendar write-back is not configured (RADICALE_URL/USER/PASS)")
|
raise CalendarRejected(503, "calendar write-back is not configured (RADICALE_URL/USER/PASS)")
|
||||||
if not owns(uid):
|
if not owns(uid):
|
||||||
raise CalendarRejected(403, "the site only manages events it created (UIDs ending %s)" % UID_SUFFIX)
|
raise CalendarRejected(403, "the site only manages its own events (%s or the seeded %s...%s)"
|
||||||
url = RADICALE_URL.rstrip("/") + "/" + slug(uid)
|
% (UID_SUFFIX, SEED_PREFIX, SEED_SUFFIX))
|
||||||
|
url = RADICALE_URL.rstrip("/") + "/" + object_name(uid)
|
||||||
req = urllib.request.Request(url, data=data, method=method)
|
req = urllib.request.Request(url, data=data, method=method)
|
||||||
req.add_header("Authorization", "Basic " + base64.b64encode(
|
req.add_header("Authorization", "Basic " + base64.b64encode(
|
||||||
("%s:%s" % (RADICALE_USER, RADICALE_PASS)).encode()).decode())
|
("%s:%s" % (RADICALE_USER, RADICALE_PASS)).encode()).decode())
|
||||||
|
|||||||
+30
-2
@@ -93,6 +93,7 @@ CAPS = {
|
|||||||
"settings:write",
|
"settings:write",
|
||||||
"apikeys:own",
|
"apikeys:own",
|
||||||
"api:docs",
|
"api:docs",
|
||||||
|
"history:read",
|
||||||
"people:invite_leader",
|
"people:invite_leader",
|
||||||
"people:invite_admin",
|
"people:invite_admin",
|
||||||
"email:draft",
|
"email:draft",
|
||||||
@@ -967,8 +968,8 @@ KEY_MAX_DAYS = 365
|
|||||||
|
|
||||||
# Scopes a key may never carry, whatever the owner holds. Minting keys from a
|
# Scopes a key may never carry, whatever the owner holds. Minting keys from a
|
||||||
# key is a loop; the rest are owner powers that belong to a person at a screen.
|
# key is a loop; the rest are owner powers that belong to a person at a screen.
|
||||||
KEY_UNSCOPABLE = {"account:self", "apikeys:own", "api:docs", "people:manage", "secrets:rotate",
|
KEY_UNSCOPABLE = {"account:self", "apikeys:own", "api:docs", "history:read", "people:manage",
|
||||||
"people:invite_leader", "people:invite_admin"}
|
"secrets:rotate", "people:invite_leader", "people:invite_admin"}
|
||||||
|
|
||||||
|
|
||||||
def scopable_caps(person):
|
def scopable_caps(person):
|
||||||
@@ -1093,3 +1094,30 @@ def api_key_person(bearer):
|
|||||||
return p
|
return p
|
||||||
finally:
|
finally:
|
||||||
con.close()
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# History. auth_events is the one action log: logins and invites since P0,
|
||||||
|
# keys since P3, calendar since P4, and every P2 write since 2026-09-04.
|
||||||
|
# One table, one screen. Not per-row history tables, and not the dropped
|
||||||
|
# generic audit table with workflow columns.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def list_events(limit=200, kind_prefix=None, before=None):
|
||||||
|
con = connect()
|
||||||
|
try:
|
||||||
|
sql = "SELECT e.*, p.email AS actor_email FROM auth_events e LEFT JOIN people p ON p.id = e.actor_id"
|
||||||
|
where, vals = [], []
|
||||||
|
if kind_prefix:
|
||||||
|
where.append("e.kind LIKE ?"); vals.append(kind_prefix + "%")
|
||||||
|
if before:
|
||||||
|
where.append("e.at < ?"); vals.append(before)
|
||||||
|
if where:
|
||||||
|
sql += " WHERE " + " AND ".join(where)
|
||||||
|
# rowid breaks ties: `at` is second-resolution and a screen can write
|
||||||
|
# several rows in one second.
|
||||||
|
sql += " ORDER BY e.at DESC, e.rowid DESC LIMIT ?"
|
||||||
|
vals.append(max(1, min(int(limit), 500)))
|
||||||
|
return [dict(r) for r in con.execute(sql, vals)]
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|||||||
+41
-2
@@ -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)
|
"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()
|
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("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))
|
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("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))
|
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)
|
raises("unconfigured put is 503", 503, C.CalendarRejected, C.put_event, uid, ev)
|
||||||
check("and nothing was sent", calls == [])
|
check("and nothing was sent", calls == [])
|
||||||
C.RADICALE_URL = "http://radicale.test/scouts/site73/"; C.RADICALE_USER = "scoutsite"; C.RADICALE_PASS = "p"
|
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")
|
raises("foreign uid delete is 403", 403, C.CalendarRejected, C.delete_event, "x@band.us")
|
||||||
check("still nothing sent for foreign uids", calls == [])
|
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"
|
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])
|
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")
|
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")
|
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