Files
Aether/scripts/caldav-selbsttest.py
marcandClaude Opus 5 8fdde182b7 feat(calendar): Abgleich nur der Aenderungen (sync-collection, RFC 6578)
Bis hierher holte jeder Durchgang die volle Terminliste: ctag abfragen,
Aenderung sehen, alles neu laden. Eine verschobene Besprechung kostete
damit so viel wie ein Neuanfang -- bei einem Jahr Termine, alle fuenfzehn
Minuten, auf Akku und Datenvolumen des Telefons.

Kein zweiter Zaehler: Das ctag **ist** bereits eine Zahl je Kalender, die
bei jedem Schreiben um eins steigt. Jedes Objekt merkt sich in sync_seq den
Stand, bei dem es zuletzt angefasst wurde (Migration 24).

Grabsteine (cal_tombstones) halten geloeschte UIDs fest -- ohne sie bliebe
ein geloeschter Termin auf jedem Geraet stehen, das gerade nicht verbunden
war. Sie stehen 180 Tage; beim Raeumen wandert sync_floor mit, und ein
Token darunter bekommt 403 <valid-sync-token/> statt einer Antwort mit
fehlenden Loeschungen.

go-webdav kennt fuer REPORT nur calendar-query und calendar-multiget, und
weder sync-token noch supported-report-set. Beide gehen denselben Weg wie
getctag und calendar-color. Ohne supported-report-set probiert DAVx5
sync-collection gar nicht erst aus.

Zwei Fehler noch vor dem Ausliefern gefunden:

* Die Anfuehrungszeichen am ETag standen **innerhalb** der Maskierung --
  xml.EscapeText machte daraus &#34;. Gueltig, aber genug Clients
  vergleichen ETags roh und saehen bei jedem Durchgang eine Aenderung.
* pruneTombstones nahm die erste Zeile eines DELETE ... RETURNING als
  Maximum. Das hat keine Reihenfolge -- der sync_floor waere zu niedrig
  gesetzt worden, und ein Client dazwischen haette Loeschungen verpasst.

caldav-selbsttest.py: 62 -> 86 Pruefungen.

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

550 lines
27 KiB
Python
Raw Permalink 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 des CalDAV-Wegs gegen eine laufende Instanz.
python3 scripts/caldav-selbsttest.py [BASIS-URL] [BENUTZER] [PASSWORT]
Der Schwestertest kalender-selbsttest.py prüft die JSON-Schnittstelle, also
das, was die Web-Oberfläche benutzt. Dieser hier geht den Weg, den ein echter
Client geht: DAVx5, Thunderbird, Apple Kalender. Beide Wege führen auf
dieselben Daten, aber über völlig verschiedenen Code – ein Fehler im einen
sagt nichts über den anderen.
Legt sich ein eigenes App-Passwort und einen eigenen Kalender an und räumt
beides am Ende wieder weg. Die vorhandenen Kalender werden nur gelesen.
"""
import http.cookiejar
import json
import re
import sys
import uuid
import urllib.error
import urllib.request
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 = []
hinweise = []
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 hinweis(text):
hinweise.append(text)
print(f" \033[33m!\033[0m {text}")
# ── JSON-Schnittstelle: Anmelden, App-Passwort besorgen ─────────────────
jar = http.cookiejar.CookieJar()
OPENER = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def api(method, path, body=None, headers=None):
data = json.dumps(body).encode() if body is not None else None
hdrs = dict(headers or {})
hdrs["Content-Type"] = "application/json"
req = urllib.request.Request(BASE + path, data=data, headers=hdrs, method=method)
try:
with OPENER.open(req) as r:
payload = r.read().decode()
return r.status, (json.loads(payload).get("data") if payload else None)
except urllib.error.HTTPError as e:
return e.code, None
st, login = api("POST", "/api/v1/auth/login", {"username": USER, "password": PASSWORT})
assert st == 200, f"Anmeldung fehlgeschlagen: {st}"
CSRF = {"X-CSRF-Token": login["csrf_token"]}
st, pw = api("POST", "/api/v1/app-passwords", {"name": "caldav-selbsttest"}, CSRF)
assert st == 201, f"App-Passwort anlegen fehlgeschlagen: {st}"
APP_PW, PW_ID = pw["password"], pw["id"]
st, testcal = api("POST", "/api/v1/calendars",
{"name": "CalDAV-Selbsttest", "color": "#a142f4"}, CSRF)
assert st == 201, f"Testkalender anlegen fehlgeschlagen: {st}"
CAL = testcal["id"]
st, zielcal = api("POST", "/api/v1/calendars",
{"name": "CalDAV-Selbsttest Ziel", "color": "#34a853"}, CSRF)
ZIEL = zielcal["id"]
def aufraeumen():
api("DELETE", f"/api/v1/calendars/{CAL}", None, CSRF)
api("DELETE", f"/api/v1/calendars/{ZIEL}", None, CSRF)
api("DELETE", f"/api/v1/app-passwords/{PW_ID}", None, CSRF)
# ── CalDAV: eigener Opener mit Basic Auth ───────────────────────────────
import base64
BASIC = "Basic " + base64.b64encode(f"{USER}:{APP_PW}".encode()).decode()
KONTO = "Basic " + base64.b64encode(f"{USER}:{PASSWORT}".encode()).decode()
HOME = f"/caldav/calendars/{USER}/"
CAL_PFAD = f"{HOME}{CAL}/"
ZIEL_PFAD = f"{HOME}{ZIEL}/"
def dav(method, path, body=None, headers=None, auth=BASIC, folge_um=True):
"""Gibt (Status, Kopfzeilen, Text) zurück. 3xx wird nicht verfolgt, wenn
folge_um falsch ist – die Umleitung selbst ist ja Teil der Prüfung."""
hdrs = {"Authorization": auth}
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)
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *a, **k):
return None
opener = urllib.request.build_opener() if folge_um else urllib.request.build_opener(NoRedirect)
try:
with opener.open(req) as r:
return r.status, r.headers, r.read().decode(errors="replace")
except urllib.error.HTTPError as e:
return e.code, e.headers, e.read().decode(errors="replace")
def propfind(path, props_xml, depth="0", auth=BASIC):
body = ('<?xml version="1.0" encoding="utf-8"?>'
'<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
'xmlns:cs="http://calendarserver.org/ns/" xmlns:i="http://apple.com/ns/ical/">'
f'<d:prop>{props_xml}</d:prop></d:propfind>')
return dav("PROPFIND", path, body,
{"Depth": depth, "Content-Type": "application/xml"}, auth=auth)
def hrefs(xml):
return re.findall(r"<href[^>]*>([^<]+)</href>", xml)
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:{end}\r\nSUMMARY:{summary}\r\n"
"END:VEVENT\r\nEND:VCALENDAR\r\n")
print(f"CalDAV-Selbsttest gegen {BASE} als {USER}\n")
try:
# ── Anmeldung und Auffinden ─────────────────────────────────────────
print("── Anmeldung ──")
st, _, _ = dav("PROPFIND", "/caldav/", auth="Basic Zm9vOmJhcg==")
check("falsches Passwort: 401", st == 401, st)
st, _, _ = dav("PROPFIND", "/caldav/", auth=KONTO)
check("Kontopasswort wird abgelehnt (nur App-Passwörter)", st == 401, st)
print("\n── Auffinden (RFC 6764) ──")
st, h, _ = dav("GET", "/.well-known/caldav", folge_um=False)
ziel = h.get("Location", "")
check(".well-known/caldav leitet um", st in (301, 302, 307, 308), st)
check("Umleitung zeigt auf den Principal", ziel.endswith(f"/caldav/principals-{USER}/"), ziel)
# Clients folgen der Umleitung mit GET. Ein 404 dort brach die
# Einrichtung ab – deshalb bleibt diese Prüfung stehen.
st, _, _ = dav("GET", f"/caldav/principals-{USER}/")
check("GET auf den Principal antwortet (kein 404)", st == 200, st)
st, _, x = propfind("/caldav/", "<d:current-user-principal/>")
check("current-user-principal", st == 207 and f"/caldav/principals-{USER}/" in x, st)
st, _, x = propfind(f"/caldav/principals-{USER}/", "<c:calendar-home-set/>")
check("calendar-home-set", st == 207 and HOME in x, x[:200])
print("\n── Kalenderliste ──")
st, _, x = propfind(HOME, "<d:resourcetype/><d:displayname/>"
"<c:supported-calendar-component-set/>", depth="1")
check("Sammlung listet auf (207)", st == 207, st)
check("Testkalender ist dabei", CAL_PFAD in hrefs(x), hrefs(x))
check("als calendar gekennzeichnet", "<calendar xmlns=\"urn:ietf:params:xml:ns:caldav\">" in x
or "<calendar/>" in x or "<calendar " in x)
check("Anzeigename kommt mit", "CalDAV-Selbsttest" in x)
check("VEVENT als unterstützte Komponente", 'name="VEVENT"' in x)
st, _, x = propfind(CAL_PFAD, "<cs:getctag/><i:calendar-color/>")
def prop_status(xml_text, prop):
"""Status des propstat-Blocks, in dem die Eigenschaft steht.
Ein PROPFIND antwortet nicht mit einem Status, sondern mit einem je
Gruppe: bekannte Eigenschaften unter 200, unbekannte unter 404. Nur
wer den zugehörigen Block liest, weiß, ob eine Eigenschaft wirklich
geliefert wurde."""
for block in xml_text.split("<propstat")[1:]:
if "<" + prop in block or ":" + prop in block:
m = re.search(r"HTTP/1\.1 (\d+)", block)
return int(m.group(1)) if m else 0
return 0
def wert(xml_text, prop):
m = re.search(rf"<{prop}[^>]*>([^<]*)</{prop}>", xml_text)
return m.group(1) if m else None
# Diese beiden kennt go-webdav nicht; Aether ergänzt sie selbst
# (backend/internal/calendar/propfind.go). Genau deshalb gehören sie
# geprüft: Sie hängen an eigenem Code, nicht an der Bibliothek.
check("getctag wird geliefert (200)", prop_status(x, "getctag") == 200,
prop_status(x, "getctag"))
check("calendar-color wird geliefert (200)", prop_status(x, "calendar-color") == 200,
prop_status(x, "calendar-color"))
check("Farbe in Apple-Schreibweise #RRGGBBAA",
wert(x, "calendar-color") == "#A142F4FF", wert(x, "calendar-color"))
# Ein ctag, das sich nicht ändert, ist schlimmer als gar keins: Der
# Client fragt einmal und hält den Kalender danach für unverändert.
vor = wert(x, "getctag")
st_, tmp = api("POST", f"/api/v1/calendars/{CAL}/events",
{"summary": "ctag-Probe", "start": "2026-12-01T09:00:00Z",
"end": "2026-12-01T10:00:00Z", "all_day": False}, CSRF)
_, _, x2 = propfind(CAL_PFAD, "<cs:getctag/>")
check("ctag ändert sich, wenn sich etwas ändert", wert(x2, "getctag") != vor,
f"{vor} -> {wert(x2, 'getctag')}")
api("DELETE", f"/api/v1/calendars/{CAL}/events/{tmp['uid']}", None, CSRF)
# Fragt ein Client *nur* danach, bliebe für go-webdav ein leeres <prop>
# übrig – das weist es ab. Aether schiebt dann einen Platzhalter unter.
_, _, x3 = propfind(CAL_PFAD, "<cs:getctag/>")
check("Anfrage nur nach getctag funktioniert", prop_status(x3, "getctag") == 200,
x3[:200])
# In der Übersicht (Depth 1 auf das Home-Set) muss jeder Kalender seine
# eigene Farbe tragen – dort holt sich ein Client sie beim Einrichten.
_, _, x4 = propfind(HOME, "<d:displayname/><i:calendar-color/>", depth="1")
farben = re.findall(r"<calendar-color[^>]*>([^<]*)</calendar-color>", x4)
check("Kalenderliste trägt die Farben", "#A142F4FF" in farben and "#34A853FF" in farben,
farben)
# Und eine Anfrage, in der nichts davon vorkommt, darf auch nichts davon
# zurückbekommen – sonst antwortete der Server auf ungestellte Fragen.
_, _, x5 = propfind(CAL_PFAD, "<d:displayname/>")
check("ohne Nachfrage keine Zusatz-Eigenschaften",
"getctag" not in x5 and "calendar-color" not in x5, x5[:200])
# ── Termine schreiben und lesen wie ein Client ──────────────────────
print("\n── Termin anlegen, ändern, löschen (PUT/GET/DELETE) ──")
uid = "selbsttest-1"
pfad = f"{CAL_PFAD}{uid}.ics"
st, h, _ = dav("PUT", pfad, ICS.format(uid=uid, start="20261110T090000Z",
end="20261110T100000Z", summary="Per CalDAV"),
{"Content-Type": "text/calendar; charset=utf-8"})
etag = h.get("ETag", "")
check("PUT legt an (201)", st == 201, st)
check("ETag in der Antwort", bool(etag), h)
st, h, body = dav("GET", pfad)
check("GET liefert den Termin", st == 200 and "SUMMARY:Per CalDAV" in body, st)
check("Content-Type ist text/calendar", "text/calendar" in h.get("Content-Type", ""),
h.get("Content-Type"))
st, _, x = propfind(CAL_PFAD, "<d:getetag/><d:getcontenttype/>", depth="1")
check("PROPFIND listet die Datei", pfad in hrefs(x), hrefs(x))
check("mit getetag", "getetag" in x)
st, _, _ = dav("PUT", pfad, ICS.format(uid=uid, start="20261110T090000Z",
end="20261110T100000Z", summary="Geaendert"),
{"Content-Type": "text/calendar", "If-Match": '"falsch"'})
check("PUT mit falschem If-Match: 412", st == 412, st)
st, _, _ = dav("PUT", pfad, ICS.format(uid=uid, start="20261110T090000Z",
end="20261110T100000Z", summary="Geaendert"),
{"Content-Type": "text/calendar", "If-Match": etag})
# 201 statt 204 beim Überschreiben kommt aus go-webdav und ist eine
# Ungenauigkeit gegenüber RFC 4918, die kein Client bestraft: Alle prüfen
# auf 2xx. Deshalb hier 2xx und nicht der genaue Wert.
check("PUT mit richtigem If-Match geht durch", 200 <= st < 300, st)
st, _, body = dav("GET", pfad)
check("Änderung ist drin", "SUMMARY:Geaendert" in body)
# Gegenrichtung: Was ein Client schreibt, muss die Oberfläche sehen.
st, evs = api("GET", f"/api/v1/calendars/{CAL}/events"
"?from=2026-01-01T00:00:00Z&to=2027-01-01T00:00:00Z")
check("per CalDAV angelegter Termin steht in der Web-Liste",
any(e["uid"] == uid and e["summary"] == "Geaendert" for e in evs or []), evs)
print("\n── REPORT: das, womit Clients wirklich abgleichen ──")
q = ('<?xml version="1.0" encoding="utf-8"?>'
'<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
'<d:prop><d:getetag/><c:calendar-data/></d:prop>'
'<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT">'
'<c:time-range start="{a}" end="{b}"/>'
'</c:comp-filter></c:comp-filter></c:filter></c:calendar-query>')
st, _, x = dav("REPORT", CAL_PFAD, q.format(a="20261101T000000Z", b="20261201T000000Z"),
{"Depth": "1", "Content-Type": "application/xml"})
check("calendar-query im Zeitfenster findet ihn", st == 207 and pfad in hrefs(x), st)
check("calendar-data ist dabei", "BEGIN:VEVENT" in x)
st, _, x = dav("REPORT", CAL_PFAD, q.format(a="20270101T000000Z", b="20270201T000000Z"),
{"Depth": "1", "Content-Type": "application/xml"})
check("außerhalb des Fensters nicht", pfad not in hrefs(x), hrefs(x))
mg = ('<?xml version="1.0" encoding="utf-8"?>'
'<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
'<d:prop><d:getetag/><c:calendar-data/></d:prop>'
f'<d:href>{pfad}</d:href></c:calendar-multiget>')
st, _, x = dav("REPORT", CAL_PFAD, mg, {"Depth": "1", "Content-Type": "application/xml"})
check("calendar-multiget liefert die angefragte Datei",
st == 207 and "SUMMARY:Geaendert" in x, st)
# ── Was die Oberfläche schreibt, muss beim Client richtig ankommen ──
print("\n── Von der Weboberfläche geschrieben, per CalDAV gelesen ──")
st, ganztag = api("POST", f"/api/v1/calendars/{CAL}/events",
{"summary": "Ganztag", "start": "2026-09-03T00:00:00Z",
"end": "2026-09-03T00:00:00Z", "all_day": True}, CSRF)
_, _, body = dav("GET", f"{CAL_PFAD}{ganztag['uid']}.ics")
check("ganztägig als reines Datum", "DTSTART;VALUE=DATE:20260903" in body,
[l for l in body.splitlines() if "DTSTART" in l])
check("DTEND ist der Folgetag (exklusiv, RFC 5545)",
"DTEND;VALUE=DATE:20260904" in body,
[l for l in body.splitlines() if "DTEND" in l])
st, farbig = api("POST", f"/api/v1/calendars/{CAL}/events",
{"summary": "Farbe", "start": "2026-09-16T10:00:00Z",
"end": "2026-09-16T11:00:00Z", "all_day": False,
"color": "#ea4335"}, CSRF)
_, _, body = dav("GET", f"{CAL_PFAD}{farbig['uid']}.ics")
check("Terminfarbe als CSS3-Name (das liest DAVx5)", "COLOR:tomato" in body,
[l for l in body.splitlines() if "COLOR" in l])
st, reihe = api("POST", f"/api/v1/calendars/{CAL}/events",
{"summary": "Reihe", "start": "2026-10-05T08:00:00Z",
"end": "2026-10-05T09:00:00Z", "all_day": False,
"recurrence": {"freq": "WEEKLY", "interval": 1, "count": 4}}, CSRF)
_, _, body = dav("GET", f"{CAL_PFAD}{reihe['uid']}.ics")
check("Wiederholung als RRULE", "RRULE:" in body and "FREQ=WEEKLY" in body,
[l for l in body.splitlines() if "RRULE" in l])
api("DELETE", f"/api/v1/calendars/{CAL}/events/{reihe['uid']}"
"?occurrence=2026-10-12T08:00:00Z", None, CSRF)
_, _, body = dav("GET", f"{CAL_PFAD}{reihe['uid']}.ics")
check("gestrichener Einzeltermin als EXDATE",
any("EXDATE" in l and "20261012" in l for l in body.splitlines()),
[l for l in body.splitlines() if "EXDATE" in l])
print("\n── Umhängen: was der Client davon sieht ──")
_, _, vorher_quelle = propfind(CAL_PFAD, "<d:getetag/>", depth="1")
st, _ = api("POST", f"/api/v1/calendars/{CAL}/events/{reihe['uid']}/move",
{"calendar_id": ZIEL}, CSRF)
_, _, nachher_quelle = propfind(CAL_PFAD, "<d:getetag/>", depth="1")
_, _, nachher_ziel = propfind(ZIEL_PFAD, "<d:getetag/>", depth="1")
alt = f"{CAL_PFAD}{reihe['uid']}.ics"
neu = f"{ZIEL_PFAD}{reihe['uid']}.ics"
check("verschwindet aus der Quell-Sammlung",
alt in hrefs(vorher_quelle) and alt not in hrefs(nachher_quelle))
check("taucht in der Ziel-Sammlung auf", neu in hrefs(nachher_ziel), hrefs(nachher_ziel))
st, _, body = dav("GET", neu)
check("dort abrufbar, UID unverändert", st == 200 and f"UID:{reihe['uid']}" in body, st)
st, _, _ = dav("GET", alt)
check("unter der alten Adresse weg (404)", st == 404, st)
print("\n── Kalender verwalten (vom Client aus) ──")
EIGEN = f"/caldav/calendars/{USER}/selbsttest-arbeit/"
mk = ('<?xml version="1.0" encoding="utf-8"?>'
'<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
'xmlns:i="http://apple.com/ns/ical/"><d:set><d:prop>'
'<d:displayname>Arbeit</d:displayname>'
'<i:calendar-color>#FF6D00FF</i:calendar-color>'
'</d:prop></d:set></c:mkcalendar>')
st, _, _ = dav("MKCALENDAR", EIGEN, mk, {"Content-Type": "application/xml"})
check("MKCALENDAR legt an (201)", st == 201, st)
# Der Kern der Sache: Der Client sucht die Adresse aus, und unter genau
# dieser Adresse muss der Kalender danach auch erreichbar sein.
st, _, x = propfind(EIGEN, "<d:displayname/><i:calendar-color/>")
check("unter der gewählten Adresse erreichbar", st == 207 and "Arbeit" in x, st)
check("Farbe aus dem MKCALENDAR übernommen",
wert(x, "calendar-color") == "#FF6D00FF", wert(x, "calendar-color"))
_, cals_jetzt = api("GET", "/api/v1/calendars")
eigen = [c for c in cals_jetzt or [] if c["name"] == "Arbeit"]
check("taucht in der Weboberfläche auf", len(eigen) == 1, [c["name"] for c in cals_jetzt or []])
st, _, _ = dav("PUT", EIGEN + "mk1.ics",
ICS.format(uid="mk1", start="20261201T090000Z",
end="20261201T100000Z", summary="Im eigenen Kalender"),
{"Content-Type": "text/calendar"})
check("Termin darin anlegen", st == 201, st)
st, _, body = dav("GET", EIGEN + "mk1.ics")
check("und wieder abrufen", st == 200 and "Im eigenen Kalender" in body, st)
st, _, _ = dav("MKCALENDAR", EIGEN, mk, {"Content-Type": "application/xml"})
check("zweites MKCALENDAR auf dieselbe Adresse: 405", st == 405, st)
pp = ('<?xml version="1.0" encoding="utf-8"?>'
'<d:propertyupdate xmlns:d="DAV:" xmlns:i="http://apple.com/ns/ical/">'
'<d:set><d:prop><d:displayname>Arbeit neu</d:displayname>'
'<i:calendar-color>#34A853FF</i:calendar-color></d:prop></d:set>'
'<d:set><d:prop><d:getetag>x</d:getetag></d:prop></d:set>'
'</d:propertyupdate>')
st, _, x = dav("PROPPATCH", EIGEN, pp, {"Content-Type": "application/xml"})
check("PROPPATCH antwortet mit 207", st == 207, st)
# PROPPATCH kann teilweise gelingen. Deshalb ein Status je Eigenschaft –
# ein nacktes 200 verschwiege, welcher Teil angekommen ist.
check("Name und Farbe mit 200",
prop_status(x, "displayname") == 200 and prop_status(x, "calendar-color") == 200, x)
check("unveränderliche Eigenschaft mit 403", prop_status(x, "getetag") == 403, x)
_, _, x = propfind(EIGEN, "<d:displayname/><i:calendar-color/>")
check("Umbenennen ist angekommen", "Arbeit neu" in x, x[:200])
check("Umfärben ist angekommen", wert(x, "calendar-color") == "#34A853FF",
wert(x, "calendar-color"))
st, _, _ = dav("DELETE", EIGEN)
check("DELETE löscht den Kalender (204)", st == 204, st)
st, _, _ = propfind(EIGEN, "<d:displayname/>")
check("danach 404", st == 404, st)
_, cals_jetzt = api("GET", "/api/v1/calendars")
check("auch in der Weboberfläche weg",
all(c["name"] != "Arbeit neu" for c in cals_jetzt or []),
[c["name"] for c in cals_jetzt or []])
# DAVx5 nimmt nicht MKCALENDAR, sondern den erweiterten MKCOL.
MKCOL_PFAD = f"/caldav/calendars/{USER}/selbsttest-mkcol/"
mkcol = ('<?xml version="1.0" encoding="utf-8"?>'
'<d:mkcol xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
'<d:set><d:prop><d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
'<d:displayname>Per MKCOL</d:displayname></d:prop></d:set></d:mkcol>')
st, _, _ = dav("MKCOL", MKCOL_PFAD, mkcol, {"Content-Type": "application/xml"})
check("MKCOL legt ebenfalls an (201)", st == 201, st)
st, _, x = propfind(MKCOL_PFAD, "<d:displayname/>")
check("auch dieser unter seiner Adresse erreichbar", st == 207 and "Per MKCOL" in x, st)
dav("DELETE", MKCOL_PFAD)
print("\n── Fremde Kalender ──")
st, _, _ = dav("PROPFIND", "/caldav/calendars/gibtsnicht/",
headers={"Depth": "0", "Content-Type": "application/xml"})
check("fremder Benutzerpfad: 404", st == 404, st)
# ── Abgleich nur der Änderungen (RFC 6578) ──────────────────────────
print("\n── sync-collection ──")
def sync(token, pfad=CAL_PFAD, level="1", mit_daten=False):
daten = '<c:calendar-data/>' if mit_daten else ''
body = ('<?xml version="1.0" encoding="utf-8"?>'
'<d:sync-collection xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
f'<d:sync-token>{token}</d:sync-token>'
f'<d:sync-level>{level}</d:sync-level>'
f'<d:prop><d:getetag/>{daten}</d:prop>'
'</d:sync-collection>')
return dav("REPORT", pfad, body, {"Depth": "1", "Content-Type": "application/xml"})
def token_von(xml_text):
m = re.search(r"<sync-token[^>]*>([^<]+)</sync-token>", xml_text)
return m.group(1) if m else ""
def geloeschte(xml_text):
"""hrefs, deren <response> einen 404-Status **ohne** propstat trägt."""
out = []
for teil in xml_text.split("<response>")[1:]:
if "404" in teil and "<propstat" not in teil:
m = re.search(r"<href[^>]*>([^<]+)</href>", teil)
if m:
out.append(m.group(1))
return out
# Der Kalender muss sich zuerst als tauglich melden – sonst probiert es
# kein Client aus.
st, _, body = propfind(CAL_PFAD, "<d:sync-token/><d:supported-report-set/>")
check("PROPFIND liefert ein sync-token", "<sync-token" in body and "urn:aether:sync:" in body,
body[:160])
check("supported-report-set nennt sync-collection", "sync-collection" in body, body[:200])
st, _, body = sync("")
check("erster Abgleich ohne Token: 207", st == 207, st)
erst_token = token_von(body)
check("liefert ein Token", erst_token.startswith("urn:aether:sync:"), erst_token)
check("liefert den vorhandenen Bestand", len(hrefs(body)) >= 1, len(hrefs(body)))
check("beim ersten Mal keine Löschmeldungen", geloeschte(body) == [], geloeschte(body))
# Nichts getan – der zweite Abgleich muss leer sein. Das ist der ganze
# Sinn der Übung: kein Verkehr, wenn sich nichts geändert hat.
st, _, body = sync(erst_token)
check("ohne Änderung kommt nichts zurück", st == 207 and hrefs(body) == [], hrefs(body))
check("Token bleibt gleich", token_von(body) == erst_token, token_von(body))
# Ein neuer Termin – und **nur** der darf kommen.
neu_uid = "sync-" + uuid.uuid4().hex[:8]
neu_pfad = f"{CAL_PFAD}{neu_uid}.ics"
st, _, _ = dav("PUT", neu_pfad,
ICS.format(uid=neu_uid, start="20260714T090000Z", end="20260714T100000Z",
summary="Sync-Probe"),
{"Content-Type": "text/calendar"})
check("Termin angelegt", st in (201, 204), st)
st, _, body = sync(erst_token)
treffer = hrefs(body)
check("genau ein geänderter Termin", len(treffer) == 1, treffer)
check("und zwar der richtige", treffer and treffer[0].endswith(f"{neu_uid}.ics"), treffer)
check("mit ETag in Anführungszeichen", re.search(r'<getetag[^>]*>"[^"]+"</getetag>', body) is not None,
body[:200])
zweit_token = token_von(body)
check("neues Token ist ein anderes", zweit_token != erst_token, zweit_token)
# Daten gleich mitliefern, wenn der Client danach fragt.
st, _, body = sync(erst_token, mit_daten=True)
check("calendar-data auf Wunsch dabei", "BEGIN:VCALENDAR" in body and "Sync-Probe" in body,
body[:200])
# Löschen: Der nächste Abgleich muss es melden – sonst bliebe der Termin
# auf jedem Gerät stehen, das gerade nicht verbunden war.
st, _, _ = dav("DELETE", neu_pfad)
check("Termin gelöscht", st in (200, 204), st)
st, _, body = sync(zweit_token)
weg = geloeschte(body)
check("Löschung wird gemeldet", len(weg) == 1 and weg[0].endswith(f"{neu_uid}.ics"), weg)
check("Löschung ohne propstat (404 an der Antwort)",
body.count("<propstat") == 0 or "<propstat" not in body.split("<response>")[1], body[:250])
# Wer von vorn anfängt, darf keine Grabsteine sehen: Er kennt den Termin
# gar nicht, eine Löschmeldung dafür wäre Unsinn.
st, _, body = sync("")
check("Vollabgleich zeigt keine Grabsteine", geloeschte(body) == [], geloeschte(body))
print("\n── sync-collection: was schiefgehen darf ──")
st, _, body = sync(erst_token, level="")
check("ohne sync-level: 400", st == 400, st)
st, _, body = sync("urn:aether:sync:abc")
check("unlesbares Token: 403 valid-sync-token",
st == 403 and "valid-sync-token" in body, f"{st} {body[:120]}")
st, _, body = sync("urn:fremd:4711")
check("Token einer fremden Instanz: 403", st == 403 and "valid-sync-token" in body, st)
st, _, body = sync("urn:aether:sync:999999999")
check("Token aus der Zukunft: 403", st == 403 and "valid-sync-token" in body, st)
st, _, body = sync(erst_token, pfad=f"{CAL_PFAD}{neu_uid}.ics")
check("sync-collection auf einem Termin: 403", st == 403, st)
# calendar-query muss weiterhin die Bibliothek bedienen – der Umweg für
# sync-collection darf die anderen Berichte nicht abschneiden.
abfrage = ('<?xml version="1.0" encoding="utf-8"?>'
'<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
'<d:prop><d:getetag/></d:prop>'
'<c:filter><c:comp-filter name="VCALENDAR"/></c:filter></c:calendar-query>')
st, _, body = dav("REPORT", CAL_PFAD, abfrage,
{"Depth": "1", "Content-Type": "application/xml"})
check("calendar-query geht weiterhin durch", st == 207, st)
print("\n── Löschen ──")
st, _, _ = dav("DELETE", pfad)
check("DELETE räumt weg (204)", st in (200, 204), st)
st, _, _ = dav("GET", pfad)
check("danach 404", st == 404, st)
finally:
aufraeumen()
print(f"\n{ok_count} bestanden, {len(fail)} fehlgeschlagen")
for h in hinweise:
print(f"Hinweis: {h}")
if fail:
print("Fehlgeschlagen: " + ", ".join(fail))
sys.exit(1 if fail else 0)