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>
329 lines
16 KiB
Python
329 lines
16 KiB
Python
#!/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)
|