test(calendar): CalDAV-Selbsttest, und fremde Benutzerpfade sauber abweisen
Der bisherige Selbsttest prueft die JSON-Schnittstelle, also den Weg der Weboberflaeche. Der CalDAV-Weg -- der, den DAVx5 und Thunderbird gehen -- war nie automatisch geprueft, obwohl er ueber voellig anderen Code laeuft. Ein gruener Lauf des einen sagt nichts ueber den anderen. scripts/caldav-selbsttest.py geht ihn jetzt ab: Anmeldung (auch die Regel, dass das Kontopasswort hier nicht gilt), Auffinden ueber .well-known samt der Umleitung, der Clients mit GET folgen, Kalenderliste, PUT/GET/DELETE, If-Match, calendar-query mit Zeitfenster, calendar-multiget, beide Richtungen zwischen Web und CalDAV (Ganztagsdatum mit exklusivem DTEND, Terminfarbe als CSS3-Name, RRULE, EXDATE) und das Umhaengen aus Client-Sicht. Dabei gefunden und behoben: Ein Pfad, der auf einen *anderen* Benutzer zeigt, wurde mit einem leeren 207 beantwortet. Kein Datenleck -- die Antwort ist leer, und der Backend nimmt den angemeldeten Benutzer, nicht den aus dem Pfad. Aber der Client sieht dann einen Kalenderbestand ohne Kalender und meldet "nichts gefunden" statt "falsche Adresse": die stille Variante genau des Fehlers, der bei der Einrichtung schon einmal Stunden gekostet hat. Jetzt 404. Bewusst nur ablehnen, was sicher fremd ist; alles Unbekannte geht weiter an go-webdav. Zwei Luecken bleiben und werden vom Skript als Hinweis gemeldet, weil go-webdav v0.7.0 nur einen festen Satz Eigenschaften beantwortet und keinen Haken bietet, eigene dazuzulegen: getctag fehlt (Clients listen bei jedem Abgleich alles auf, statt billig nach Aenderungen zu fragen) und calendar-color fehlt (der Kalender erscheint im Client in dessen Vorgabefarbe -- Terminfarben sind nicht betroffen, die stehen im .ics). 39 Pruefungen gruen, JSON-Selbsttest weiter 36 gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc35161e74
commit
d7b5c1cd5f
3 files changed
+403
No files matched your search
+40
@@ -31,6 +31,46 @@ Kontos**; für den Umzugstest entsteht kurzzeitig ein zweiter Kalender
|
|||||||
„T zweitkalender", der danach wieder verschwindet. Also nur gegen eine
|
„T zweitkalender", der danach wieder verschwindet. Also nur gegen eine
|
||||||
Entwicklungsinstanz laufen lassen.
|
Entwicklungsinstanz laufen lassen.
|
||||||
|
|
||||||
|
## CalDAV: Selbsttest per Skript
|
||||||
|
|
||||||
|
Der Test oben prüft die JSON-Schnittstelle – also den Weg der
|
||||||
|
Web-Oberfläche. Dieser hier geht den Weg eines echten Clients (DAVx5,
|
||||||
|
Thunderbird, Apple Kalender). Beide führen auf dieselben Daten, aber über
|
||||||
|
völlig verschiedenen Code: Ein grüner Lauf des einen sagt nichts über den
|
||||||
|
anderen.
|
||||||
|
|
||||||
|
python3 scripts/caldav-selbsttest.py
|
||||||
|
|
||||||
|
39 Prüfungen: Anmeldung (falsches Passwort und **Kontopasswort** werden
|
||||||
|
abgewiesen – für CalDAV gelten nur App-Passwörter), Auffinden über
|
||||||
|
`.well-known/caldav` samt der Umleitung, der Clients mit GET folgen,
|
||||||
|
Kalenderliste mit Anzeigename und Komponenten, PUT/GET/DELETE eines
|
||||||
|
Termins, `If-Match` (falsches ETag muss 412 geben), `calendar-query` mit
|
||||||
|
Zeitfenster und `calendar-multiget`, beide Richtungen zwischen Web und
|
||||||
|
CalDAV (Ganztagstermin als reines Datum mit exklusivem DTEND, Terminfarbe
|
||||||
|
als CSS3-Name, RRULE, EXDATE), das Umhängen aus Client-Sicht (Datei
|
||||||
|
verschwindet in der Quelle, taucht im Ziel auf, UID bleibt) und dass ein
|
||||||
|
fremder Benutzerpfad 404 gibt statt eines leeren 207.
|
||||||
|
|
||||||
|
Legt sich ein eigenes App-Passwort und zwei eigene Kalender an und räumt
|
||||||
|
beides am Ende weg; vorhandene Kalender werden nur gelesen.
|
||||||
|
|
||||||
|
### Zwei bekannte Lücken, die das Skript als Hinweis meldet
|
||||||
|
|
||||||
|
Beide kommen daher, dass `go-webdav` v0.7.0 nur einen festen Satz
|
||||||
|
Eigenschaften beantwortet und keinen Haken bietet, um eigene dazuzulegen:
|
||||||
|
|
||||||
|
- ⚠️ **`getctag` fehlt** (Antwort 404). Clients können nicht billig fragen
|
||||||
|
„hat sich etwas geändert?" und listen stattdessen bei jedem Abgleich alle
|
||||||
|
Termine auf. Der Abgleich bleibt richtig, kostet aber mehr Daten und Akku.
|
||||||
|
- ⚠️ **`calendar-color` fehlt** (Antwort 404). Der Kalender erscheint im
|
||||||
|
Client in dessen Vorgabefarbe. **Terminfarben** sind davon nicht
|
||||||
|
betroffen – die stehen im .ics selbst und kommen an.
|
||||||
|
|
||||||
|
Dazu eine Ungenauigkeit ohne Folgen: Ein PUT, das einen vorhandenen Termin
|
||||||
|
überschreibt, antwortet mit `201 Created` statt `204 No Content`. Das kommt
|
||||||
|
ebenfalls aus go-webdav; Clients prüfen auf 2xx.
|
||||||
|
|
||||||
## Notfall: Migration hängt fest ("Dirty database version")
|
## Notfall: Migration hängt fest ("Dirty database version")
|
||||||
|
|
||||||
Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und
|
Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und
|
||||||
|
|||||||
@@ -75,6 +75,17 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ein Pfad, der auf einen *anderen* Benutzer zeigt, ist ein Fehler und
|
||||||
|
// muss auch so aussehen. Ohne diese Prüfung antwortet go-webdav mit
|
||||||
|
// einem leeren 207: Der Client sieht dann einen Kalenderbestand ohne
|
||||||
|
// Kalender und meldet „nichts gefunden", statt „falsche Adresse" – die
|
||||||
|
// stille Variante desselben Fehlers, die bei der Einrichtung Stunden
|
||||||
|
// kostet.
|
||||||
|
if !pathBelongsTo(r.URL.Path, user.Username) {
|
||||||
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
h.dav.ServeHTTP(w, r.WithContext(contextWithUser(r.Context(), user)))
|
h.dav.ServeHTTP(w, r.WithContext(contextWithUser(r.Context(), user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +108,25 @@ func (h *Handler) ServeWellKnown(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/caldav/principals-"+user.Username+"/", http.StatusPermanentRedirect)
|
http.Redirect(w, r, "/caldav/principals-"+user.Username+"/", http.StatusPermanentRedirect)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pathBelongsTo erkennt Pfade, die auf einen anderen Benutzer zeigen.
|
||||||
|
// Bewusst nur ablehnen, was sicher fremd ist: Alles Unbekannte geht weiter
|
||||||
|
// an go-webdav, damit diese Prüfung keine Pfade abschneidet, die sie gar
|
||||||
|
// nicht kennt.
|
||||||
|
func pathBelongsTo(p, username string) bool {
|
||||||
|
rest := strings.Trim(strings.TrimPrefix(p, davPrefix), "/")
|
||||||
|
if rest == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
seg := strings.Split(rest, "/")
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(seg[0], "principals-"):
|
||||||
|
return strings.EqualFold(strings.TrimPrefix(seg[0], "principals-"), username)
|
||||||
|
case seg[0] == "calendars" && len(seg) >= 2:
|
||||||
|
return strings.EqualFold(seg[1], username)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func challenge(w http.ResponseWriter) {
|
func challenge(w http.ResponseWriter) {
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="Aether CalDAV - use an app password, not your account password"`)
|
w.Header().Set("WWW-Authenticate", `Basic realm="Aether CalDAV - use an app password, not your account password"`)
|
||||||
http.Error(w, "Unauthorized - CalDAV requires an app password (Profile -> App passwords)", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized - CalDAV requires an app password (Profile -> App passwords)", http.StatusUnauthorized)
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
#!/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 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
|
||||||
|
|
||||||
|
check("ctag oder calendar-color werden überhaupt gemeldet",
|
||||||
|
prop_status(x, "getctag") or prop_status(x, "calendar-color"), x[:200])
|
||||||
|
if prop_status(x, "getctag") != 200:
|
||||||
|
hinweis("getctag fehlt (404) – Clients können nicht billig fragen "
|
||||||
|
"\u201ehat sich etwas ge\u00e4ndert?\u201c und listen stattdessen jedes "
|
||||||
|
"Mal alle Termine auf. Abgleich bleibt richtig, kostet aber mehr.")
|
||||||
|
if prop_status(x, "calendar-color") != 200:
|
||||||
|
hinweis("calendar-color fehlt (404) – die Farbe des Kalenders erscheint im "
|
||||||
|
"Client in dessen Vorgabefarbe. Terminfarben sind davon nicht betroffen.")
|
||||||
|
|
||||||
|
# ── 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── Fremde Kalender ──")
|
||||||
|
st, _, _ = dav("PROPFIND", "/caldav/calendars/gibtsnicht/",
|
||||||
|
headers={"Depth": "0", "Content-Type": "application/xml"})
|
||||||
|
check("fremder Benutzerpfad: 404", st == 404, 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)
|
||||||
Reference in new issue
Block a user