From 128d54035a6a31fd50a3b8384290aa1b99892102 Mon Sep 17 00:00:00 2001 From: marc Date: Sat, 5 Sep 2026 18:11:25 +0200 Subject: [PATCH] feat(calendar): getctag und calendar-color fuer CalDAV-Clients Beide Eigenschaften fragt jeder verbreitete Client ab, und beide kennt go-webdav v0.7.0 nicht: getctag die billige Frage "hat sich in diesem Kalender etwas geaendert?". Ohne sie laedt ein Client bei jedem Abgleich die Liste aller Termine herunter, um sie zu vergleichen -- richtig, aber teuer an Daten und Akku. calendar-color die Farbe des Kalenders. Ohne sie zeigt das Telefon ihn in seiner eigenen Vorgabefarbe, und die Farbgebung dort hat mit der in der Cloud nichts mehr zu tun. Die Bibliothek beantwortet einen festen Satz Eigenschaften und bietet keinen Haken, eigene dazuzulegen (server.go, propFindCalendar). Deshalb faengt der Handler PROPFIND jetzt ab: Er nimmt die beiden aus der Anfrage heraus, bevor go-webdav sie zu sehen bekommt, und haengt sie der Antwort als eigenen propstat-Block wieder an. Das Herausnehmen ist der Punkt, an dem es sonst schiefgeht: Ohne das meldet go-webdav sie als "nicht gefunden", und dieselbe Eigenschaft stuende zweimal in derselben Antwort -- einmal mit 404, einmal mit 200. Was ein Client daraus macht, ist Gluecksache. Zwei Fallen dabei, beide abgedeckt: Fragt ein Client *nur* nach diesen Eigenschaften, bliebe ein leeres uebrig, das go-webdav abweist -- dann schiebt Aether resourcetype als Platzhalter unter. Und die Farbe geht als #RRGGBBAA hinaus, mit Alpha-Anteil, weil manche Clients sie sonst gar nicht anzeigen. Der Neuaufbau des Anfragerumpfs ist verlustfrei, weil in einem PROPFIND ausschliesslich leere Elemente stehen: Name und Namensraum sind alles, was eine Eigenschaft dort ausmacht. Unit-Tests fuer die XML-Arbeit (propfind_test.go), CalDAV-Selbsttest von 39 auf 45 Pruefungen: beide Eigenschaften mit 200, Apple-Schreibweise, ctag aendert sich bei Aenderungen (ein festgefrorenes ctag ist schlimmer als gar keins), Anfrage nur nach ihnen, Farben in der Kalenderliste, und ohne Nachfrage kommt auch nichts. Co-Authored-By: Claude Opus 5 --- TESTING.md | 44 +-- backend/internal/calendar/handler.go | 13 +- backend/internal/calendar/propfind.go | 295 +++++++++++++++++++++ backend/internal/calendar/propfind_test.go | 116 ++++++++ scripts/caldav-selbsttest.py | 52 +++- 5 files changed, 492 insertions(+), 28 deletions(-) create mode 100644 backend/internal/calendar/propfind.go create mode 100644 backend/internal/calendar/propfind_test.go diff --git a/TESTING.md b/TESTING.md index 04dbc3f..d68273e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -41,35 +41,43 @@ anderen. python3 scripts/caldav-selbsttest.py -39 Prüfungen: Anmeldung (falsches Passwort und **Kontopasswort** werden +45 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. +Kalenderliste mit Anzeigename, Komponenten, `getctag` und +`calendar-color`, 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 +### Zwei Eigenschaften kommen nicht aus der Bibliothek -Beide kommen daher, dass `go-webdav` v0.7.0 nur einen festen Satz -Eigenschaften beantwortet und keinen Haken bietet, um eigene dazuzulegen: +`go-webdav` v0.7.0 beantwortet in PROPFIND einen festen Satz Eigenschaften +und bietet keinen Haken, eigene dazuzulegen. `getctag` (Calendarserver) und +`calendar-color` (Apple) ergänzt Aether deshalb selbst +(`backend/internal/calendar/propfind.go`): Beide werden aus der Anfrage +genommen, bevor go-webdav sie zu sehen bekommt, und der Antwort als eigener +`propstat`-Block wieder angehängt. Ohne das Herausnehmen stünde dieselbe +Eigenschaft zweimal in der Antwort – einmal mit 404, einmal mit 200. -- ⚠️ **`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. +Weil das eigener Code ist und nicht Bibliothek, prüft das Skript ihn +besonders: dass beide mit 200 kommen, die Farbe in Apple-Schreibweise +(`#RRGGBBAA`), dass das ctag sich bei jeder Änderung **ändert** (ein +festgefrorenes ctag ist schlimmer als gar keins – der Client fragt einmal +und hält den Kalender danach für unverändert), dass eine Anfrage nach +*nur* diesen Eigenschaften funktioniert (sonst bliebe für go-webdav ein +leeres `` übrig, das es abweist), und dass ohne Nachfrage auch nichts +davon in der Antwort steht. 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. +aus go-webdav; Clients prüfen auf 2xx. ## Notfall: Migration hängt fest ("Dirty database version") diff --git a/backend/internal/calendar/handler.go b/backend/internal/calendar/handler.go index e276637..8cd49a1 100644 --- a/backend/internal/calendar/handler.go +++ b/backend/internal/calendar/handler.go @@ -24,6 +24,7 @@ const ( // Handler serves CalDAV at /caldav/ with HTTP Basic Auth (App-Passwörter). type Handler struct { authStore *auth.Store + store *Store dav *caldav.Handler limiter *middleware.FailureLimiter } @@ -31,6 +32,7 @@ type Handler struct { func NewHandler(authStore *auth.Store, store *Store) *Handler { return &Handler{ authStore: authStore, + store: store, dav: &caldav.Handler{Backend: NewBackend(store), Prefix: "/caldav"}, limiter: middleware.NewFailureLimiter(davMaxFailures, davWindow, davLockout), } @@ -86,7 +88,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - h.dav.ServeHTTP(w, r.WithContext(contextWithUser(r.Context(), user))) + r = r.WithContext(contextWithUser(r.Context(), user)) + + // PROPFIND geht über einen eigenen Weg: Er ergänzt die Antwort um + // Eigenschaften, die go-webdav nicht kennt (siehe propfind.go). + if r.Method == "PROPFIND" { + h.servePropfind(w, r, user) + return + } + + h.dav.ServeHTTP(w, r) } // ServeWellKnown beantwortet /.well-known/caldav – der Pfad, mit dem diff --git a/backend/internal/calendar/propfind.go b/backend/internal/calendar/propfind.go new file mode 100644 index 0000000..a4dbeb5 --- /dev/null +++ b/backend/internal/calendar/propfind.go @@ -0,0 +1,295 @@ +package calendar + +import ( + "bytes" + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/google/uuid" + + "aether/internal/auth" +) + +// Zwei Eigenschaften, die jeder verbreitete CalDAV-Client abfragt und die +// go-webdav v0.7.0 nicht kennt: +// +// - getctag (Calendarserver): die billige Frage „hat sich in diesem +// Kalender etwas geändert?". Ohne sie lädt ein Client bei jedem Abgleich +// die Liste aller Termine herunter, um sie zu vergleichen. +// - calendar-color (Apple): die Farbe des Kalenders. Ohne sie zeigt der +// Client ihn in seiner eigenen Vorgabefarbe, und die Farbgebung im +// Telefon hat mit der in der Cloud nichts mehr zu tun. +// +// Die Bibliothek beantwortet in PROPFIND einen festen Satz Eigenschaften und +// bietet keinen Haken, eigene dazuzulegen (server.go, propFindCalendar). +// Deshalb dieser Umweg: Wir nehmen die beiden aus der Anfrage heraus, bevor +// go-webdav sie zu sehen bekommt, und hängen sie der Antwort wieder an. +// +// Warum herausnehmen und nicht einfach anhängen? Weil go-webdav sie sonst +// als „nicht gefunden" meldet und dieselbe Eigenschaft in derselben Antwort +// zweimal stünde – einmal mit 404, einmal mit 200. Was ein Client daraus +// macht, ist Glückssache. +const ( + nsCalendarServer = "http://calendarserver.org/ns/" + nsApple = "http://apple.com/ns/ical/" +) + +// Ein PROPFIND-Rumpf ist eine Liste leerer Elemente. Alles darüber ist kein +// Abfragerumpf mehr, sondern ein Angriff auf den Arbeitsspeicher. +const maxPropfindBody = 1 << 20 + +type wantedExtras struct{ ctag, color bool } + +func (w wantedExtras) any() bool { return w.ctag || w.color } + +// parsePropfind liest, welche der beiden Zusatz-Eigenschaften gefragt sind, +// und baut den Rumpf ohne sie neu auf. +// +// Der Neuaufbau ist verlustfrei, weil in einem PROPFIND ausschließlich leere +// Elemente stehen: Name und Namensraum sind alles, was eine Eigenschaft dort +// ausmacht. +func parsePropfind(body []byte) (want wantedExtras, rewritten []byte) { + // Ein leerer Rumpf bedeutet „alle Eigenschaften" (RFC 4918, 9.1). + if len(bytes.TrimSpace(body)) == 0 { + return wantedExtras{ctag: true, color: true}, body + } + + dec := xml.NewDecoder(bytes.NewReader(body)) + var ( + inProp bool + depth int + keep []xml.Name + ) + for { + tok, err := dec.Token() + if err != nil { + break + } + switch t := tok.(type) { + case xml.StartElement: + if t.Name.Space == "DAV:" && t.Name.Local == "allprop" { + return wantedExtras{ctag: true, color: true}, body + } + if !inProp { + if t.Name.Space == "DAV:" && t.Name.Local == "prop" { + inProp, depth = true, 0 + } + continue + } + depth++ + if depth > 1 { + continue // verschachtelte Inhalte gibt es in PROPFIND nicht + } + switch { + case t.Name.Space == nsCalendarServer && t.Name.Local == "getctag": + want.ctag = true + case t.Name.Space == nsApple && t.Name.Local == "calendar-color": + want.color = true + default: + keep = append(keep, t.Name) + } + case xml.EndElement: + if inProp { + if depth == 0 { + inProp = false + } else { + depth-- + } + } + } + } + + if !want.any() { + return want, body // nichts zu tun – Rumpf unverändert weiterreichen + } + return want, buildPropfind(keep) +} + +func buildPropfind(props []xml.Name) []byte { + var b strings.Builder + b.WriteString(``) + for _, n := range props { + if n.Space == "DAV:" { + fmt.Fprintf(&b, "<%s/>", xmlName(n.Local)) + } else { + fmt.Fprintf(&b, "<%s xmlns=\"%s\"/>", xmlName(n.Local), xmlAttr(n.Space)) + } + } + // Fragt ein Client *nur* nach unseren beiden Eigenschaften, bliebe hier + // ein leeres stehen – damit weist go-webdav die Anfrage ab. + // resourcetype kennt jede Ressource und stört keinen Client. + if len(props) == 0 { + b.WriteString("") + } + b.WriteString("") + return []byte(b.String()) +} + +// Elementnamen und Namensräume stammen aus fremdem XML und wandern hier +// zurück in XML. Alles, was dort etwas anderes bedeuten könnte, fliegt raus – +// gültige Namen und URIs enthalten davon ohnehin nichts. +func xmlName(s string) string { + return strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', + r == '-', r == '_', r == '.': + return r + } + return -1 + }, s) +} + +func xmlAttr(s string) string { + r := strings.NewReplacer("&", "&", "\"", """, "<", "<", ">", ">") + return r.Replace(s) +} + +// appleColor bringt eine Farbe in die Schreibweise, die Apple-Clients und +// DAVx5 erwarten: #RRGGBBAA. Ohne den Alpha-Anteil zeigen manche Clients +// die Farbe gar nicht an. +func appleColor(hex string) string { + h := normalizeHex(hex) + if h == "" { + return "" + } + return strings.ToUpper(h) + "FF" +} + +func extrasPropstat(cal *Calendar, want wantedExtras) string { + var b strings.Builder + if want.ctag { + fmt.Fprintf(&b, `%d`, nsCalendarServer, cal.CTag) + } + if want.color { + if c := appleColor(cal.Color); c != "" { + fmt.Fprintf(&b, `%s`, nsApple, c) + } + } + if b.Len() == 0 { + return "" + } + // Mehrere propstat je Antwort sind der Normalfall in WebDAV – so trennt + // go-webdav ohnehin schon gefundene von nicht gefundenen Eigenschaften. + return `` + b.String() + + `HTTP/1.1 200 OK` +} + +// injectExtras hängt jeder Antwort, die einen Kalender beschreibt, unsere +// Eigenschaften an. Antworten für das Home-Set oder für einzelne Termine +// bleiben unangetastet. +func (h *Handler) injectExtras(ctx context.Context, user *auth.User, body []byte, want wantedExtras) []byte { + cals, err := h.store.ListCalendars(ctx, user.ID) + if err != nil { + return body // im Zweifel die Antwort der Bibliothek, unverändert + } + byID := make(map[uuid.UUID]*Calendar, len(cals)) + for i := range cals { + byID[cals[i].ID] = &cals[i] + } + + const closeTag = "" + parts := strings.Split(string(body), closeTag) + var out strings.Builder + for i, part := range parts { + out.WriteString(part) + if i == len(parts)-1 { + break // hinter dem letzten steht nur noch + } + if cal := calendarOfResponse(part, byID); cal != nil { + out.WriteString(extrasPropstat(cal, want)) + } + out.WriteString(closeTag) + } + return []byte(out.String()) +} + +// calendarOfResponse findet den Kalender, den ein -Abschnitt +// beschreibt – oder nil, wenn er etwas anderes beschreibt. +func calendarOfResponse(part string, byID map[uuid.UUID]*Calendar) *Calendar { + start := strings.Index(part, "") + if open < 0 { + return nil + } + rest := part[start+open+1:] + end := strings.Index(rest, "") + if end < 0 { + return nil + } + href := rest[:end] + if p, err := url.PathUnescape(href); err == nil { + href = p + } + calID, objekt, err := parseCalendarPath(href) + if err != nil || objekt != "" { + return nil // Home-Set, Principal oder ein einzelner Termin + } + return byID[calID] +} + +// recorder hält die Antwort der Bibliothek fest, bis feststeht, ob noch +// etwas hineingehört. Nötig, weil die Länge sich ändert und Content-Length +// erst danach stimmt. +type recorder struct { + header http.Header + body bytes.Buffer + status int +} + +func (rec *recorder) Header() http.Header { return rec.header } + +func (rec *recorder) WriteHeader(code int) { + if rec.status == 0 { + rec.status = code + } +} + +func (rec *recorder) Write(p []byte) (int, error) { + if rec.status == 0 { + rec.status = http.StatusOK + } + return rec.body.Write(p) +} + +// servePropfind reicht die Anfrage an go-webdav weiter und ergänzt die +// Antwort um die Eigenschaften, die die Bibliothek nicht kennt. +func (h *Handler) servePropfind(w http.ResponseWriter, r *http.Request, user *auth.User) { + body, err := io.ReadAll(io.LimitReader(r.Body, maxPropfindBody)) + if err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + want, rewritten := parsePropfind(body) + + r.Body = io.NopCloser(bytes.NewReader(rewritten)) + r.ContentLength = int64(len(rewritten)) + if !want.any() { + h.dav.ServeHTTP(w, r) + return + } + + rec := &recorder{header: http.Header{}} + h.dav.ServeHTTP(rec, r) + + out := rec.body.Bytes() + if rec.status == http.StatusMultiStatus { + out = h.injectExtras(r.Context(), user, out, want) + } + for k, v := range rec.header { + w.Header()[k] = v + } + w.Header().Set("Content-Length", strconv.Itoa(len(out))) + w.WriteHeader(rec.status) + if _, err := w.Write(out); err != nil { + return + } +} diff --git a/backend/internal/calendar/propfind_test.go b/backend/internal/calendar/propfind_test.go new file mode 100644 index 0000000..6817e51 --- /dev/null +++ b/backend/internal/calendar/propfind_test.go @@ -0,0 +1,116 @@ +package calendar + +import ( + "strings" + "testing" + + "github.com/google/uuid" +) + +const propfindHead = ` +` + +func TestParsePropfindTrenntUnsereEigenschaftenAb(t *testing.T) { + body := []byte(propfindHead + ``) + + want, rewritten := parsePropfind(body) + if !want.ctag || !want.color { + t.Fatalf("beide Eigenschaften sollten erkannt werden, bekam %+v", want) + } + out := string(rewritten) + if !strings.Contains(out, "") { + t.Errorf("die übrigen Eigenschaften müssen erhalten bleiben: %s", out) + } + // Der Kern: go-webdav darf sie nicht zu sehen bekommen, sonst meldet es + // sie als 404 und dieselbe Eigenschaft stünde zweimal in der Antwort. + if strings.Contains(out, "getctag") || strings.Contains(out, "calendar-color") { + t.Errorf("unsere Eigenschaften müssen entfernt sein: %s", out) + } +} + +func TestParsePropfindLaesstFremdeAnfragenInRuhe(t *testing.T) { + body := []byte(propfindHead + ``) + + want, rewritten := parsePropfind(body) + if want.any() { + t.Errorf("nichts von uns gefragt, trotzdem %+v", want) + } + if string(rewritten) != string(body) { + t.Errorf("Rumpf muss unverändert durchgereicht werden") + } +} + +func TestParsePropfindNurUnsereEigenschaftHinterlaesstKeinLeeresProp(t *testing.T) { + // Ein leeres weist go-webdav ab – dann käme auf eine gültige + // Anfrage ein Fehler statt einer Antwort. + body := []byte(propfindHead + ``) + + _, rewritten := parsePropfind(body) + if !strings.Contains(string(rewritten), "") { + t.Errorf("Platzhalter fehlt: %s", rewritten) + } +} + +func TestParsePropfindAllpropUndLeererRumpf(t *testing.T) { + for _, tc := range []struct { + name string + body []byte + }{ + {"allprop", []byte(propfindHead + ``)}, + {"leerer Rumpf", nil}, // RFC 4918, 9.1: leer bedeutet allprop + } { + want, rewritten := parsePropfind(tc.body) + if !want.ctag || !want.color { + t.Errorf("%s: sollte alles liefern, bekam %+v", tc.name, want) + } + if string(rewritten) != string(tc.body) { + t.Errorf("%s: Rumpf darf nicht umgebaut werden", tc.name) + } + } +} + +func TestCalendarOfResponseTrifftNurKalender(t *testing.T) { + id := uuid.New() + cal := &Calendar{ID: id, Color: "#a142f4", CTag: 7} + byID := map[uuid.UUID]*Calendar{id: cal} + + tests := []struct { + name string + href string + will *Calendar + }{ + {"Kalender", "/caldav/calendars/max/" + id.String() + "/", cal}, + {"Home-Set", "/caldav/calendars/max/", nil}, + {"Principal", "/caldav/principals-max/", nil}, + {"einzelner Termin", "/caldav/calendars/max/" + id.String() + "/abc.ics", nil}, + {"fremder Kalender", "/caldav/calendars/max/" + uuid.New().String() + "/", nil}, + } + for _, tc := range tests { + part := `` + tc.href + `` + if got := calendarOfResponse(part, byID); got != tc.will { + t.Errorf("%s: bekam %v, erwartet %v", tc.name, got, tc.will) + } + } +} + +func TestExtrasPropstat(t *testing.T) { + cal := &Calendar{Color: "#a142f4", CTag: 12} + + got := extrasPropstat(cal, wantedExtras{ctag: true, color: true}) + if !strings.Contains(got, ">12") { + t.Errorf("ctag fehlt: %s", got) + } + // Apple-Clients und DAVx5 erwarten den Alpha-Anteil. + if !strings.Contains(got, ">#A142F4FF") { + t.Errorf("Farbe nicht in Apple-Schreibweise: %s", got) + } + if !strings.Contains(got, "HTTP/1.1 200 OK") { + t.Errorf("Status fehlt: %s", got) + } + + // Ohne Farbe bleibt die Eigenschaft weg – lieber gar nicht melden als + // leer, sonst färbt der Client den Kalender schwarz. + if got := extrasPropstat(&Calendar{CTag: 1}, wantedExtras{color: true}); got != "" { + t.Errorf("ohne Farbe sollte nichts entstehen, bekam %s", got) + } +} diff --git a/scripts/caldav-selbsttest.py b/scripts/caldav-selbsttest.py index 5b91f9b..58d76c5 100644 --- a/scripts/caldav-selbsttest.py +++ b/scripts/caldav-selbsttest.py @@ -185,15 +185,49 @@ try: 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.") + def wert(xml_text, prop): + m = re.search(rf"<{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, "") + 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 + # übrig – das weist es ab. Aether schiebt dann einen Platzhalter unter. + _, _, x3 = propfind(CAL_PFAD, "") + 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, "", depth="1") + farben = re.findall(r"]*>([^<]*)", 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, "") + 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) ──")