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)