diff --git a/TESTING.md b/TESTING.md
index 9306eb3..04dbc3f 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -31,6 +31,46 @@ Kontos**; für den Umzugstest entsteht kurzzeitig ein zweiter Kalender
„T zweitkalender", der danach wieder verschwindet. Also nur gegen eine
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")
Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und
diff --git a/backend/internal/calendar/handler.go b/backend/internal/calendar/handler.go
index d2e1187..e276637 100644
--- a/backend/internal/calendar/handler.go
+++ b/backend/internal/calendar/handler.go
@@ -75,6 +75,17 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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)))
}
@@ -97,6 +108,25 @@ func (h *Handler) ServeWellKnown(w http.ResponseWriter, r *http.Request) {
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) {
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)
diff --git a/scripts/caldav-selbsttest.py b/scripts/caldav-selbsttest.py
new file mode 100644
index 0000000..5b91f9b
--- /dev/null
+++ b/scripts/caldav-selbsttest.py
@@ -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 = (''
+ ''
+ f'{props_xml}')
+ return dav("PROPFIND", path, body,
+ {"Depth": depth, "Content-Type": "application/xml"}, auth=auth)
+
+
+def hrefs(xml):
+ return re.findall(r"]*>([^<]+)", 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/", "")
+ check("current-user-principal", st == 207 and f"/caldav/principals-{USER}/" in x, st)
+
+ st, _, x = propfind(f"/caldav/principals-{USER}/", "")
+ check("calendar-home-set", st == 207 and HOME in x, x[:200])
+
+ print("\n── Kalenderliste ──")
+ st, _, x = propfind(HOME, ""
+ "", 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", "" in x
+ or "" in x or "")
+
+ 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("", 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 = (''
+ ''
+ ''
+ ''
+ ''
+ '')
+ 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 = (''
+ ''
+ ''
+ f'{pfad}')
+ 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, "", depth="1")
+ st, _ = api("POST", f"/api/v1/calendars/{CAL}/events/{reihe['uid']}/move",
+ {"calendar_id": ZIEL}, CSRF)
+ _, _, nachher_quelle = propfind(CAL_PFAD, "", depth="1")
+ _, _, nachher_ziel = propfind(ZIEL_PFAD, "", 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)