Files
Aether/scripts/kalender-teilen-selbsttest.py
T
marcandClaude Opus 5 5d90c0a2d2 feat(calendar): Kalender an Nutzer und Gruppen freigeben
Nicht als eigene Tabelle, wie im Plan vorgesehen: acl_entries beantwortet
fuer Ordner und Dateien laengst dieselbe Frage -- wer darf was, direkt oder
ueber eine Gruppe. Ein zweites Rechtesystem daneben waere eine zweite
Antwort auf dieselbe Frage, mit eigener Gruppenaufloesung, eigenem
Widerruf, eigenem Pruefpfad; die Luecke dazwischen faellt erst auf, wenn
jemand sieht, was er nicht sehen soll. Migration 25 haengt deshalb nur eine
dritte Ressourcenspalte an.

Der Gewinn: Freigeben an eine **Gruppe** kostete keine Zeile extra.

Drei Entscheidungen, die nicht offensichtlich waren:

* Ein fremder Kalender steht immer unter seiner UUID, nie unter dem
  Pfadsegment des Eigentuemers -- das hat der sich ausgesucht, und zwei
  Leute duerfen denselben Namen waehlen. Sonst laegen im Kalenderbestand
  des Empfaengers zwei Kalender auf derselben Adresse.
* Alle Wege laufen durch RequireCalendar. CalDAV und Weboberflaeche sind
  zwei getrennte Zugaenge zu denselben Daten; ein Recht, das nur einer von
  beiden prueft, ist kein Recht. Im CalDAV-Teil genuegte, resolveCalendar
  umzustellen -- dort kommt jede Operation vorbei.
* Wer ein Recht geliehen hat, darf es nicht weiterverleihen: Umbenennen,
  Loeschen und Freigeben bleiben auch mit Schreibrecht beim Eigentuemer.

Oberflaeche: fremde Kalender tragen Eigentuemer und "(nur lesen)", das
Menue zeigt nur, was auch geht, und ein Termin laesst sich gar nicht erst
in einen Kalender ziehen, den der Server danach ablehnen wuerde.

scripts/kalender-teilen-selbsttest.py, 41 Pruefungen, jede ueber beide
Zugaenge. Er beginnt damit, dass der Empfaenger vorher nichts sieht --
ohne diesen Anfang beweist der Rest nichts.

Dabei hatte der Test selbst eine Falle: Mit festem Testdatum lief die
Terminliste aus ihrem Standardzeitraum, und die Pruefung "beim Eigentuemer
ist nichts angekommen" bestand **leer**.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:46:18 +02:00

329 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Selbsttest: Kalender an andere Konten freigeben.
python3 scripts/kalender-teilen-selbsttest.py [BASIS-URL] [BENUTZER] [PASSWORT]
Eine Rechtefunktion ist genau so viel wert wie ihre engste Stelle. Aether hat
**zwei** Zugänge zu denselben Kalendern – die Weboberfläche (JSON) und CalDAV –,
und beide prüfen getrennt. Ein Recht, das nur einer von beiden durchsetzt, ist
kein Recht; deshalb geht hier jede Prüfung über **beide** Wege.
Geprüft wird in dieser Reihenfolge, und die Reihenfolge ist der Punkt:
1. **Vorher sieht der Empfänger nichts.** Ohne diesen Anfang beweist der
Rest nichts – ein Kalender, den ohnehin jeder sieht, ist auch nach dem
Freigeben sichtbar.
2. Nur lesend: sehen ja, ändern nein.
3. Schreibend: ändern ja.
4. Was auch schreibend **nicht** geht: den fremden Kalender umbenennen,
löschen oder weiterverschenken.
5. Über eine Gruppe – derselbe Weg, ohne eine Zeile eigenen Code.
6. Widerruf: danach wieder unsichtbar, auf beiden Wegen.
Der Test legt zwei Konten, eine Gruppe und einen Kalender an und räumt am
Ende alles weg.
"""
import base64
import datetime
import http.cookiejar
import json
import re
import sys
import urllib.error
import urllib.request
import uuid
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8090").rstrip("/")
USER = sys.argv[2] if len(sys.argv) > 2 else "admin"
PASSWORT = sys.argv[3] if len(sys.argv) > 3 else "admin123"
ok_count = 0
fail = []
def check(name, cond, detail=""):
global ok_count
if cond:
ok_count += 1
print(f" \033[32mok\033[0m {name}")
else:
fail.append(name)
print(f" \033[31mFEHL\033[0m {name} {detail}")
def sitzung():
jar = http.cookiejar.CookieJar()
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def ruf(opener, method, path, body=None, headers=None):
hdrs = dict(headers or {})
data = None
if body is not None:
data = json.dumps(body).encode()
hdrs["Content-Type"] = "application/json"
req = urllib.request.Request(BASE + path, data=data, headers=hdrs, method=method)
try:
with opener.open(req) as r:
text = r.read().decode()
return r.status, (json.loads(text).get("data") if text else None)
except urllib.error.HTTPError as e:
return e.code, None
def anmelden(user, passwort):
o = sitzung()
st, d = ruf(o, "POST", "/api/v1/auth/login", {"username": user, "password": passwort})
assert st == 200, f"Anmeldung {user} fehlgeschlagen: {st}"
return o, {"X-CSRF-Token": d["csrf_token"]}
def dav(basic, method, path, body=None, headers=None):
hdrs = {"Authorization": basic}
hdrs.update(headers or {})
data = body.encode() if isinstance(body, str) else body
req = urllib.request.Request(BASE + path, data=data, headers=hdrs, method=method)
try:
with urllib.request.urlopen(req) as r:
return r.status, r.read().decode(errors="replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode(errors="replace")
def kalenderliste_dav(basic, username):
"""Die Kalender, die dieser Zugang über CalDAV sieht."""
body = ('<?xml version="1.0" encoding="utf-8"?>'
'<d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind>')
st, text = dav(basic, "PROPFIND", f"/caldav/calendars/{username}/", body,
{"Depth": "1", "Content-Type": "application/xml"})
return st, re.findall(r"<href[^>]*>([^<]+)</href>", text), text
# Die Terminliste hat einen Standardzeitraum (ein Monat zurueck bis ein Jahr
# voraus). Ein Testtermin mit festem Datum faellt irgendwann heraus -- und dann
# besteht die Pruefung "nichts angekommen" **leer**, was schlimmer ist als ein
# Fehlschlag. Deshalb relativ zum heutigen Tag, und der Zeitraum wird bei jeder
# Abfrage ausdruecklich mitgegeben.
_morgen = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=1)
START = _morgen.strftime("%Y%m%dT090000Z")
ENDE = _morgen.strftime("%Y%m%dT100000Z")
ZEITRAUM = ("?from=" + (_morgen - datetime.timedelta(days=2)).strftime("%Y-%m-%dT00:00:00Z")
+ "&to=" + (_morgen + datetime.timedelta(days=2)).strftime("%Y-%m-%dT00:00:00Z"))
ICS = ("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Selbsttest//DE\r\n"
"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20260101T000000Z\r\n"
"DTSTART:{start}\r\nDTEND:{ende}\r\n"
"SUMMARY:{summary}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n")
print(f"Kalender-Freigabe-Selbsttest gegen {BASE}\n")
besitzer, BES_CSRF = anmelden(USER, PASSWORT)
marke = uuid.uuid4().hex[:6]
empf_name = f"teilen-empf-{marke}"
empf_pw = "Empf!" + uuid.uuid4().hex[:12]
angelegt = {"user": None, "group": None, "cal": None, "acl": []}
def aufraeumen():
for eid in angelegt["acl"]:
ruf(besitzer, "DELETE", f"/api/v1/acl/{eid}", None, BES_CSRF)
if angelegt["cal"]:
ruf(besitzer, "DELETE", f"/api/v1/calendars/{angelegt['cal']}", None, BES_CSRF)
if angelegt["group"]:
ruf(besitzer, "DELETE", f"/api/v1/admin/groups/{angelegt['group']}", None, BES_CSRF)
if angelegt["user"]:
ruf(besitzer, "DELETE", f"/api/v1/admin/users/{angelegt['user']}", None, BES_CSRF)
try:
st, empf = ruf(besitzer, "POST", "/api/v1/admin/users",
{"username": empf_name, "display_name": "Empfaenger",
"password": empf_pw, "is_admin": False, "quota_bytes": 0}, BES_CSRF)
assert st in (200, 201), f"Empfängerkonto anlegen fehlgeschlagen: {st}"
angelegt["user"] = empf["id"]
EMPF_ID = empf["id"]
empfaenger, EMPF_CSRF = anmelden(empf_name, empf_pw)
# App-Passwörter für den CalDAV-Weg – das Kontopasswort nimmt /caldav/ nicht.
def app_passwort(opener, csrf):
st, d = ruf(opener, "POST", "/api/v1/app-passwords",
{"name": "teilen-test-" + marke}, csrf)
assert st in (200, 201), f"App-Passwort fehlgeschlagen: {st}"
return d["password"]
BES_DAV = "Basic " + base64.b64encode(f"{USER}:{app_passwort(besitzer, BES_CSRF)}".encode()).decode()
EMPF_DAV = "Basic " + base64.b64encode(f"{empf_name}:{app_passwort(empfaenger, EMPF_CSRF)}".encode()).decode()
st, kal = ruf(besitzer, "POST", "/api/v1/calendars",
{"name": "Geteilt " + marke, "color": "#aa3366", "description": ""}, BES_CSRF)
assert st in (200, 201), f"Kalender anlegen fehlgeschlagen: {st}"
angelegt["cal"] = kal["id"]
CAL = kal["id"]
uid = "teilen-" + marke
st, _ = dav(BES_DAV, "PUT", f"/caldav/calendars/{USER}/{CAL}/{uid}.ics",
ICS.format(uid=uid, summary="Besprechung", start=START, ende=ENDE), {"Content-Type": "text/calendar"})
assert st in (201, 204), f"Termin anlegen fehlgeschlagen: {st}"
def sichtbar_json():
st, cals = ruf(empfaenger, "GET", "/api/v1/calendars", None, EMPF_CSRF)
return [c for c in (cals or []) if c["id"] == CAL]
def sichtbar_dav():
st, hrefs, _ = kalenderliste_dav(EMPF_DAV, empf_name)
return [h for h in hrefs if CAL in h]
# ── 1. Vorher ────────────────────────────────────────────────────────────
print("── Vorher: der Empfänger sieht nichts ──")
check("nicht in seiner Kalenderliste (Web)", sichtbar_json() == [], sichtbar_json())
check("nicht in seinem Kalenderbestand (CalDAV)", sichtbar_dav() == [], sichtbar_dav())
st, _ = ruf(empfaenger, "GET", f"/api/v1/calendars/{CAL}/events" + ZEITRAUM, None, EMPF_CSRF)
check("Termine direkt abrufen: 404", st == 404, st)
st, _ = dav(EMPF_DAV, "GET", f"/caldav/calendars/{empf_name}/{CAL}/{uid}.ics")
check("Termin über CalDAV: 404", st == 404, st)
# ── 2. Nur lesend ────────────────────────────────────────────────────────
print("\n── Nur lesend freigegeben ──")
st, eintrag = ruf(besitzer, "POST", f"/api/v1/calendars/{CAL}/acl",
{"subject_id": EMPF_ID, "subject_type": "user", "permission": "read"},
BES_CSRF)
check("Freigabe angelegt", st in (200, 201), st)
if eintrag:
angelegt["acl"].append(eintrag["id"])
treffer = sichtbar_json()
check("jetzt in der Kalenderliste (Web)", len(treffer) == 1, treffer)
if treffer:
check("als fremd gekennzeichnet", treffer[0].get("shared") is True, treffer[0])
check("mit Recht „read\"", treffer[0].get("permission") == "read", treffer[0].get("permission"))
check("mit Namen des Eigentümers", bool(treffer[0].get("owner_name")), treffer[0].get("owner_name"))
check("jetzt auch im Kalenderbestand (CalDAV)", len(sichtbar_dav()) >= 1, sichtbar_dav())
st, ev = ruf(empfaenger, "GET", f"/api/v1/calendars/{CAL}/events" + ZEITRAUM, None, EMPF_CSRF)
check("Termine lesbar (Web)", st == 200, st)
st, text = dav(EMPF_DAV, "GET", f"/caldav/calendars/{empf_name}/{CAL}/{uid}.ics")
check("Termin lesbar (CalDAV)", st == 200 and "Besprechung" in text, st)
print(" … und nichts davon lässt sich ändern:")
st, _ = ruf(empfaenger, "POST", f"/api/v1/calendars/{CAL}/events",
{"summary": "Eingeschmuggelt", "start": "2026-08-02T09:00:00Z",
"end": "2026-08-02T10:00:00Z", "all_day": False}, EMPF_CSRF)
check("Termin anlegen (Web) abgelehnt", st in (403, 404), st)
fremd_uid = "fremd-" + marke
st, _ = dav(EMPF_DAV, "PUT", f"/caldav/calendars/{empf_name}/{CAL}/{fremd_uid}.ics",
ICS.format(uid=fremd_uid, summary="Eingeschmuggelt", start=START, ende=ENDE),
{"Content-Type": "text/calendar"})
check("Termin anlegen (CalDAV) abgelehnt", st == 403, st)
st, _ = dav(EMPF_DAV, "DELETE", f"/caldav/calendars/{empf_name}/{CAL}/{uid}.ics")
check("Termin löschen (CalDAV) abgelehnt", st == 403, st)
st, _ = ruf(empfaenger, "POST", f"/api/v1/calendars/{CAL}/import", None, EMPF_CSRF)
check("Import abgelehnt", st in (400, 403, 404), st)
# Und der Termin steht wirklich noch da – nicht nur der Statuscode stimmt.
st, ev = ruf(besitzer, "GET", f"/api/v1/calendars/{CAL}/events" + ZEITRAUM, None, BES_CSRF)
namen = [e.get("summary") for e in (ev or [])]
# **Erst prüfen, dass überhaupt etwas in der Liste steht.** Sonst besteht
# die nächste Zeile auch dann, wenn die Abfrage schlicht nichts liefert –
# und eine Prüfung, die leer besteht, prüft nichts.
check("die Terminliste des Eigentümers ist nicht leer", "Besprechung" in namen, namen)
check("beim Eigentümer ist nichts angekommen", "Eingeschmuggelt" not in namen, namen)
# ── 3. Schreibend ────────────────────────────────────────────────────────
print("\n── Auf schreibend hochgestuft ──")
st, eintrag = ruf(besitzer, "POST", f"/api/v1/calendars/{CAL}/acl",
{"subject_id": EMPF_ID, "subject_type": "user", "permission": "write"},
BES_CSRF)
check("Recht geändert statt doppelt angelegt", st in (200, 201), st)
treffer = sichtbar_json()
check("Recht jetzt „write\"", treffer and treffer[0].get("permission") == "write",
treffer[0].get("permission") if treffer else None)
st, _ = dav(EMPF_DAV, "PUT", f"/caldav/calendars/{empf_name}/{CAL}/{fremd_uid}.ics",
ICS.format(uid=fremd_uid, summary="Vom Empfaenger", start=START, ende=ENDE),
{"Content-Type": "text/calendar"})
check("Termin anlegen (CalDAV) geht jetzt", st in (201, 204), st)
st, ev = ruf(besitzer, "GET", f"/api/v1/calendars/{CAL}/events" + ZEITRAUM, None, BES_CSRF)
namen = [e.get("summary") for e in (ev or [])]
check("und kommt beim Eigentümer an", "Vom Empfaenger" in namen, namen)
# ── 4. Was auch schreibend nicht geht ────────────────────────────────────
print("\n── Grenzen des Schreibrechts ──")
st, _ = ruf(empfaenger, "PATCH", f"/api/v1/calendars/{CAL}",
{"name": "Uebernommen"}, EMPF_CSRF)
check("fremden Kalender umbenennen: abgelehnt", st in (403, 404), st)
st, _ = ruf(empfaenger, "DELETE", f"/api/v1/calendars/{CAL}", None, EMPF_CSRF)
check("fremden Kalender löschen: abgelehnt", st in (403, 404), st)
st, _ = dav(EMPF_DAV, "DELETE", f"/caldav/calendars/{empf_name}/{CAL}/")
check("fremden Kalender löschen (CalDAV): abgelehnt", st not in (200, 204), st)
st, _ = ruf(empfaenger, "GET", f"/api/v1/calendars/{CAL}/acl", None, EMPF_CSRF)
check("fremde Freigabeliste sehen: abgelehnt", st == 404, st)
st, _ = ruf(empfaenger, "POST", f"/api/v1/calendars/{CAL}/acl",
{"subject_id": EMPF_ID, "subject_type": "user", "permission": "read"}, EMPF_CSRF)
check("fremden Kalender weiterverschenken: abgelehnt", st == 404, st)
# Der Kalender heißt noch, wie er hieß.
st, kals = ruf(besitzer, "GET", "/api/v1/calendars", None, BES_CSRF)
meiner = [c for c in (kals or []) if c["id"] == CAL]
check("Name unverändert", meiner and meiner[0]["name"] == "Geteilt " + marke,
meiner[0]["name"] if meiner else None)
# ── 5. Über eine Gruppe ──────────────────────────────────────────────────
print("\n── Freigabe über eine Gruppe ──")
for eid in angelegt["acl"]:
ruf(besitzer, "DELETE", f"/api/v1/acl/{eid}", None, BES_CSRF)
angelegt["acl"] = []
check("nach Widerruf wieder unsichtbar", sichtbar_json() == [], sichtbar_json())
st, grp = ruf(besitzer, "POST", "/api/v1/admin/groups",
{"name": "teilen-gruppe-" + marke, "quota_bytes": 0}, BES_CSRF)
check("Gruppe angelegt", st in (200, 201), st)
if grp:
angelegt["group"] = grp["id"]
st, _ = ruf(besitzer, "POST", f"/api/v1/admin/groups/{grp['id']}/members",
{"user_id": EMPF_ID}, BES_CSRF)
check("Empfänger ist Mitglied", st in (200, 201, 204), st)
st, eintrag = ruf(besitzer, "POST", f"/api/v1/calendars/{CAL}/acl",
{"subject_id": grp["id"], "subject_type": "group", "permission": "read"},
BES_CSRF)
check("an die Gruppe freigegeben", st in (200, 201), st)
if eintrag:
angelegt["acl"].append(eintrag["id"])
treffer = sichtbar_json()
check("über die Gruppe sichtbar (Web)", len(treffer) == 1, treffer)
check("über die Gruppe sichtbar (CalDAV)", len(sichtbar_dav()) >= 1, sichtbar_dav())
if treffer:
check("Herkunft wird genannt", bool(treffer[0].get("via_group")), treffer[0].get("via_group"))
# ── 6. Abgleich auf einem geteilten Kalender ─────────────────────────────
print("\n── sync-collection auf einem fremden Kalender ──")
body = ('<?xml version="1.0" encoding="utf-8"?>'
'<d:sync-collection xmlns:d="DAV:"><d:sync-token/>'
'<d:sync-level>1</d:sync-level><d:prop><d:getetag/></d:prop></d:sync-collection>')
st, text = dav(EMPF_DAV, "REPORT", f"/caldav/calendars/{empf_name}/{CAL}/", body,
{"Depth": "1", "Content-Type": "application/xml"})
check("Abgleich läuft (207)", st == 207, st)
check("die Adressen zeigen auf den Empfänger",
f"/caldav/calendars/{empf_name}/{CAL}/" in text, text[:200])
# ── 7. Widerruf ──────────────────────────────────────────────────────────
print("\n── Widerruf ──")
for eid in angelegt["acl"]:
st, _ = ruf(besitzer, "DELETE", f"/api/v1/acl/{eid}", None, BES_CSRF)
check("Freigabe widerrufen", st in (200, 204), st)
angelegt["acl"] = []
check("wieder unsichtbar (Web)", sichtbar_json() == [], sichtbar_json())
check("wieder unsichtbar (CalDAV)", sichtbar_dav() == [], sichtbar_dav())
st, _ = ruf(empfaenger, "GET", f"/api/v1/calendars/{CAL}/events" + ZEITRAUM, None, EMPF_CSRF)
check("Termine wieder 404", st == 404, st)
finally:
aufraeumen()
print()
if fail:
print(f"\033[31m{len(fail)} fehlgeschlagen\033[0m: " + ", ".join(fail))
print(f"{ok_count} bestanden, {len(fail)} fehlgeschlagen")
sys.exit(1 if fail else 0)