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 ". 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>
550 lines
27 KiB
Python
550 lines
27 KiB
Python
#!/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)
|