Fix calendar-color not applied by DAVx5 (duplicate propstat)

DAVx5 explicitly requests the Apple calendar-color property by name.
go-webdav's stock property map doesn't know it, so it always emitted a
404 Not Found propstat for it. Our injection was adding a *second*,
200 OK propstat with the color into the same <response>, producing a
response with two propstats for the same property name — invalid
multistatus that real clients (dav4jvm/DAVx5) resolved by preferring the
404, so the color was silently ignored.

Now the bogus 404-only propstat for calendar-color is stripped before
injecting the 200 OK one, leaving a single, valid propstat per response.
Verified against Nextcloud's documented behavior (single propstat with
the color) and against a live PROPFIND matching DAVx5's actual request
shape.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-20 07:04:49 +02:00
co-authored by Copilot
parent ebbc7a2a2b
commit 5f72e58ad3
2 changed files with 89 additions and 6 deletions
+58
View File
@@ -213,3 +213,61 @@ func TestPropFindEmitsCalendarColor(t *testing.T) {
t.Fatalf("expected exactly one calendar-color element (only for work), got: %s", body)
}
}
// TestPropFindExplicitCalendarColorRequest verifies that when a client
// (like DAVx5) explicitly asks for the Apple calendar-color property by
// name, it gets back a single 200 OK propstat with the color — not a
// duplicate/conflicting 404 propstat alongside it, which is what
// go-webdav's stock property map produces on its own for an unknown
// property and which real clients (dav4jvm) were observed to prefer over
// the injected 200, hiding the color entirely.
func TestPropFindExplicitCalendarColorRequest(t *testing.T) {
dir := t.TempDir()
st, err := store.NewStore(filepath.Join(dir, "data"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
dbase, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { dbase.Close() })
if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
t.Fatalf("CreateUser alice: %v", err)
}
if err := dbase.CreateCalendarWithColor("alice", "work", "#3b82f6"); err != nil {
t.Fatalf("CreateCalendarWithColor: %v", err)
}
if err := st.EnsureCollection("alice", "cal-work"); err != nil {
t.Fatalf("EnsureCollection: %v", err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
handler := NewHandler(&config.Config{}, st, dbase, logger)
req := httptest.NewRequest("PROPFIND", "/cal/home/work/", strings.NewReader(
`<?xml version="1.0"?><D:propfind xmlns:D="DAV:" xmlns:A="http://apple.com/ns/ical/">`+
`<D:prop><D:displayname/><A:calendar-color/></D:prop></D:propfind>`))
req.SetBasicAuth("alice", "pw")
req.Header.Set("Content-Type", "text/xml")
req.Header.Set("Depth", "0")
req = req.WithContext(auth.NewContext(context.Background(), &auth.Principal{Username: "alice"}))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusMultiStatus {
t.Fatalf("expected 207, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if strings.Count(body, "<propstat") != 1 {
t.Fatalf("expected exactly one propstat (no leftover 404 for calendar-color), got: %s", body)
}
if !strings.Contains(body, `<calendar-color xmlns="http://apple.com/ns/ical/">#3b82f6FF</calendar-color>`) {
t.Fatalf("expected calendar-color in the single 200 OK propstat, got: %s", body)
}
if !strings.Contains(body, "200 OK") || strings.Contains(body, "404") {
t.Fatalf("expected a single 200 OK propstat with no 404, got: %s", body)
}
}