package caldav import ( "context" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "git.arnef.de/arnef/nidus/internal/auth" "git.arnef.de/arnef/nidus/internal/config" "git.arnef.de/arnef/nidus/internal/db" "git.arnef.de/arnef/nidus/internal/icssub" "git.arnef.de/arnef/nidus/internal/store" ical "github.com/emersion/go-ical" "github.com/emersion/go-webdav/caldav" ) func newTestBackend(t *testing.T) (*Backend, *db.DB) { t.Helper() 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() }) cfg := &config.Config{} if err := dbase.CreateUser("alice", "pw", "", ""); err != nil { t.Fatalf("CreateUser alice: %v", err) } if err := dbase.CreateUser("bob", "pw", "", ""); err != nil { t.Fatalf("CreateUser bob: %v", err) } if err := dbase.CreateCalendar("alice", "work"); err != nil { t.Fatalf("CreateCalendar alice/work: %v", err) } if err := dbase.CreateCalendar("bob", "personal"); err != nil { t.Fatalf("CreateCalendar bob/personal: %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) return NewBackend(cfg, st, dbase, logger, nil), dbase } func ctxFor(username string) context.Context { return auth.NewContext(context.Background(), &auth.Principal{Username: username}) } // minimalEvent returns a minimal, valid VCALENDAR/VEVENT for use in tests. func minimalEvent() *ical.Calendar { const raw = "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//nidus//test//EN\r\n" + "BEGIN:VEVENT\r\n" + "UID:event1@nidus.test\r\n" + "DTSTAMP:20240101T000000Z\r\n" + "DTSTART:20240101T100000Z\r\n" + "SUMMARY:Test Event\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode() if err != nil { panic(fmt.Sprintf("minimalEvent: %v", err)) } return cal } func TestListCalendarsIncludesSharedCalendar(t *testing.T) { b, dbase := newTestBackend(t) if err := dbase.ShareCalendar("alice", "work", "bob", db.PermRead); err != nil { t.Fatalf("ShareCalendar: %v", err) } // The shared calendar must exist on disk for it to be listed; normally // this happens when alice's own ListCalendars runs and ensures it. if _, err := b.ListCalendars(ctxFor("alice")); err != nil { t.Fatalf("ListCalendars(alice): %v", err) } cals, err := b.ListCalendars(ctxFor("bob")) if err != nil { t.Fatalf("ListCalendars: %v", err) } var found bool wantPath := calHomePath() + sharedCalendarName("alice", "work") + "/" for _, c := range cals { if c.Path == wantPath && c.Name == "work" { found = true } } if !found { t.Errorf("shared calendar with path %q and display name %q not found in ListCalendars result: %+v", wantPath, "work", cals) } } func TestSharedCalendarReadOnlyRejectsWrite(t *testing.T) { b, dbase := newTestBackend(t) if err := dbase.ShareCalendar("alice", "work", "bob", db.PermRead); err != nil { t.Fatalf("ShareCalendar: %v", err) } localName := sharedCalendarName("alice", "work") objPath := calObjectPath(localName, "event1.ics") _, err := b.PutCalendarObject(ctxFor("bob"), objPath, minimalEvent(), nil) if err == nil { t.Fatal("expected error writing to read-only shared calendar, got nil") } } func TestSharedCalendarWriteAllowed(t *testing.T) { b, dbase := newTestBackend(t) if err := dbase.ShareCalendar("alice", "work", "bob", db.PermWrite); err != nil { t.Fatalf("ShareCalendar: %v", err) } localName := sharedCalendarName("alice", "work") objPath := calObjectPath(localName, "event1.ics") if _, err := b.PutCalendarObject(ctxFor("bob"), objPath, minimalEvent(), nil); err != nil { t.Fatalf("PutCalendarObject with write share: %v", err) } // The object should now be visible under alice's own calendar too, // since it's stored in her namespace. obj, err := b.GetCalendarObject(ctxFor("alice"), calObjectPath("work", "event1.ics"), nil) if err != nil { t.Fatalf("GetCalendarObject as owner: %v", err) } if obj == nil { t.Fatal("expected non-nil object") } } func TestUnauthorizedUserCannotAccessUnsharedCalendar(t *testing.T) { b, _ := newTestBackend(t) localName := sharedCalendarName("alice", "work") _, err := b.GetCalendar(ctxFor("bob"), calHomePath()+localName+"/") if err == nil { t.Fatal("expected error accessing unshared calendar, got nil") } } // TestPropFindEmitsCalendarColor verifies that a PROPFIND on a calendar // with a color set returns the Apple/DAVx5 calendar-color property, and // that a calendar without a color doesn't (since a client should fall // back to its own default in that case). func TestPropFindEmitsCalendarColor(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 := dbase.CreateCalendar("alice", "personal"); err != nil { t.Fatalf("CreateCalendar: %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) handler := NewHandler(&config.Config{}, st, dbase, logger, nil) req := httptest.NewRequest("PROPFIND", "/cal/home/", strings.NewReader( ``)) req.SetBasicAuth("alice", "pw") req.Header.Set("Content-Type", "text/xml") req.Header.Set("Depth", "1") req = req.WithContext(ctxFor("alice")) rr := httptest.NewRecorder() req = req.WithContext(auth.NewContext(context.Background(), &auth.Principal{Username: "alice"})) 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.Contains(body, `#3b82f6FF`) { t.Fatalf("expected calendar-color for work calendar, got: %s", body) } // The "personal" calendar has no color, so its block // shouldn't contain the property at all. personalIdx := strings.Index(body, "/cal/home/personal/") if personalIdx < 0 { t.Fatalf("expected personal calendar in response, got: %s", body) } // Find personal's response block boundaries loosely by looking for the // nearest calendar-color occurrence and ensuring it isn't right next to // the personal href (colors are per-block, checked via count instead). // Two calendar-color elements are expected: one for "work" (explicit // color) and one for the always-present synthetic "birthdays" calendar // (default color, since it wasn't explicitly set here). if strings.Count(body, "`+defaultBirthdayColor+`FF`) { t.Fatalf("expected default birthdays calendar-color, 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, nil) req := httptest.NewRequest("PROPFIND", "/cal/home/work/", strings.NewReader( ``+ ``)) 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, "#3b82f6FF`) { 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) } } // TestSharedCalendarNameCollidingWithOwnGetsDisambiguated covers the case // where a calendar shared with a user has the same name as one of that // user's own calendars: DAVx5 (and any other client) would otherwise show // two calendars with an identical, indistinguishable title, so the shared // one's display name gets the owner's username appended in parentheses. func TestSharedCalendarNameCollidingWithOwnGetsDisambiguated(t *testing.T) { b, dbase := newTestBackend(t) // alice creates her own "personal" calendar, colliding with bob's // pre-existing "personal" calendar (see newTestBackend), then shares // it with bob. if err := dbase.CreateCalendar("alice", "personal"); err != nil { t.Fatalf("CreateCalendar alice/personal: %v", err) } if err := dbase.ShareCalendar("alice", "personal", "bob", db.PermRead); err != nil { t.Fatalf("ShareCalendar: %v", err) } if _, err := b.ListCalendars(ctxFor("alice")); err != nil { t.Fatalf("ListCalendars(alice): %v", err) } cals, err := b.ListCalendars(ctxFor("bob")) if err != nil { t.Fatalf("ListCalendars(bob): %v", err) } wantPath := calHomePath() + sharedCalendarName("alice", "personal") + "/" var sharedName, ownName string for _, c := range cals { if c.Path == wantPath { sharedName = c.Name } if c.Path == calHomePath()+"personal/" { ownName = c.Name } } if ownName != "personal" { t.Fatalf("expected bob's own calendar to keep its plain name, got %q", ownName) } if want := "personal (alice)"; sharedName != want { t.Fatalf("expected shared calendar display name %q, got %q", want, sharedName) } // GetCalendar (single-collection lookup, as used by PROPFIND on the // calendar's own URL) must disambiguate the same way. cal, err := b.GetCalendar(ctxFor("bob"), wantPath) if err != nil { t.Fatalf("GetCalendar: %v", err) } if want := "personal (alice)"; cal.Name != want { t.Fatalf("GetCalendar: expected display name %q, got %q", want, cal.Name) } } // TestHoistPropFilterTimeRangesUnit pins the shape of the hoist rewrite: // a prop-filter time-range (DTSTART window) is lifted onto its enclosing // VEVENT comp-filter, and the prop-filter's own Start/End are cleared, // so go-webdav's recurrence-aware comp-level check runs instead of its // literal-DTSTART prop check. func TestHoistPropFilterTimeRangesUnit(t *testing.T) { loc := time.UTC gs := time.Date(2026, 9, 1, 0, 0, 0, 0, loc) ge := time.Date(2026, 9, 30, 0, 0, 0, 0, loc) cf := caldav.CompFilter{ Name: "VCALENDAR", Comps: []caldav.CompFilter{{ Name: "VEVENT", Props: []caldav.PropFilter{{Name: "DTSTART", Start: gs, End: ge}}, }}, } hoistPropFilterTimeRanges(&cf) vevent := cf.Comps[0] if vevent.Start != gs || vevent.End != ge { t.Fatalf("VEVENT comp should inherit the time-range, got Start=%v End=%v", vevent.Start, vevent.End) } if !vevent.Props[0].Start.IsZero() || !vevent.Props[0].End.IsZero() { t.Fatalf("prop-filter time-range must be cleared, got Start=%v End=%v", vevent.Props[0].Start, vevent.Props[0].End) } // The VEVENT name is untouched — hoist must not clobber component names. if vevent.Name != "VEVENT" { t.Fatalf("VEVENT comp name must be preserved, got %q", vevent.Name) } } // rruleSample is a weekly series whose base DTSTART sits in the past // (2025) relative to the queried 2026 window — the classic shape that // go-webdav's literal-DTSTART prop-time-range check drops, but that a // proper RRULE expansion keeps. const rruleSample = "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//nidus//test//EN\r\n" + "BEGIN:VEVENT\r\n" + "UID:weekly@nidus.test\r\n" + "DTSTAMP:20260101T000000Z\r\n" + "DTSTART:20250911T100000Z\r\n" + // 2025 base, far before the 2026-09 window "DTEND:20250911T103000Z\r\n" + "RRULE:FREQ=WEEKLY;UNTIL=20270826T080000Z;INTERVAL=3;BYDAY=TH;WKST=SU\r\n" + "SUMMARY:Frontend Weekly\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" // TestQueryCalendarObjectsPropFilterTimeRangeRecurring is the regression // test for the "CalDAV still misses Frontend Weekly / Abstimmung / // Prd-Deployment" report. DAVx5 and Thunderbird issue calendar-query // filters as // *inside* a VEVENT comp-filter. go-webdav's matchPropTimeRange only // compares the literal DTSTART (2025), so without the hoist the 2026-09 // query returns an empty result for a weekly series whose base date is // in 2025. The hoist lifts the time-range onto the comp-filter, where // go-webdav's recurrence-aware matchCompTimeRange (RecurrenceSet) keeps // the series. func TestQueryCalendarObjectsPropFilterTimeRangeRecurring(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(rruleSample)) })) defer srv.Close() 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("Open db: %v", err) } defer dbase.Close() if err := dbase.CreateUser("alice", "pw", "", ""); err != nil { t.Fatalf("CreateUser: %v", err) } if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil { t.Fatalf("CreateICSSubscription: %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) b := NewBackend(&config.Config{}, st, dbase, logger, icssub.NewCache(time.Hour)) ctx := auth.NewContext(context.Background(), &auth.Principal{Username: "alice"}) loc := time.UTC gs := time.Date(2026, 9, 1, 0, 0, 0, 0, loc) ge := time.Date(2026, 9, 30, 0, 0, 0, 0, loc) // The DAVx5/Thunderbird form: a prop-filter time-range on DTSTART // nested inside a VEVENT comp-filter under VCALENDAR. q := &caldav.CalendarQuery{ CompFilter: caldav.CompFilter{ Name: "VCALENDAR", Comps: []caldav.CompFilter{{ Name: "VEVENT", Props: []caldav.PropFilter{{Name: "DTSTART", Start: gs, End: ge}}, }}, }, } objs, err := b.QueryCalendarObjects(ctx, "/cal/home/holidays/", q) if err != nil { t.Fatalf("QueryCalendarObjects: %v", err) } if len(objs) == 0 { t.Fatal("QueryCalendarObjects with a DAVx5-shape prop-filter time-range " + "returned 0 objects for a weekly series whose RRULE has 2026-09 " + "occurrences, but whose base DTSTART is 2025 — this is the " + "regression the hoist fix exists to prevent") } found := false for _, co := range objs { for _, ev := range co.Data.Events() { if p := ev.Props.Get(ical.PropSummary); p != nil && p.Value == "Frontend Weekly" { found = true } } } if !found { t.Fatal("expected 'Frontend Weekly' in the query result") } } // TestReportPropFilterTimeRangeRecurringE2E drives the *full* HTTP stack a // real CalDAV client uses: NewHandler → go-webdav caldav.Handler → XML decode // of the // body → // QueryCalendarObjects (hoist) → caldav.Filter. The in-process tests above // hand-build the CompFilter struct and therefore skip the XML-decode step, // so this is the only test that can catch a bug in how the client's actual // request gets parsed. DAVx5/Thunderbird send exactly this shape. func TestReportPropFilterTimeRangeRecurringE2E(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/calendar; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(rruleSample)) })) defer srv.Close() 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) } defer dbase.Close() if err := dbase.CreateUser("alice", "pw", "", ""); err != nil { t.Fatalf("CreateUser: %v", err) } if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil { t.Fatalf("CreateICSSubscription: %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) cfg := &config.Config{} cfg.Auth.Realm = "test" handlers := auth.NewMiddleware(cfg, dbase, logger) httpd := httptest.NewServer(handlers.Wrap(NewHandler(cfg, st, dbase, logger, icssub.NewCache(time.Hour)))) defer httpd.Close() reportXML := ` ` req, err := http.NewRequest("REPORT", httpd.URL+"/cal/home/holidays/", strings.NewReader(reportXML)) if err != nil { t.Fatalf("NewRequest: %v", err) } req.SetBasicAuth("alice", "pw") req.Header.Set("Content-Type", "application/xml; charset=utf-8") req.Header.Set("Depth", "1") resp, err := httpd.Client().Do(req) if err != nil { t.Fatalf("Do: %v", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusMultiStatus { t.Fatalf("REPORT status = %d, want 207; body:\n%s", resp.StatusCode, body) } if !strings.Contains(string(body), "Frontend Weekly") { t.Fatalf("REPORT response did not include the recurring 'Frontend Weekly' series. This is the exact DAVx5/Thunderbird request shape; body:\n%s", body) } } // TestCloseOpenEndedTimeRangesUnit exercises closeOpenEndedTimeRanges // directly: a comp-filter with only Start set (no End) must get End // filled in with farFutureSentinel, and a comp-filter with neither set // must be left alone (it has no time-range at all, so there's nothing to // "close"). func TestCloseOpenEndedTimeRangesUnit(t *testing.T) { gs := time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC) cf := caldav.CompFilter{ Name: "VCALENDAR", Comps: []caldav.CompFilter{ {Name: "VEVENT", Start: gs}, // open-ended: no End {Name: "VTODO"}, // no time-range at all }, } closeOpenEndedTimeRanges(&cf) if cf.Comps[0].End != farFutureSentinel { t.Fatalf("open-ended VEVENT comp-filter should get End=farFutureSentinel, got %v", cf.Comps[0].End) } if !cf.Comps[1].Start.IsZero() || !cf.Comps[1].End.IsZero() { t.Fatalf("VTODO comp-filter without any time-range must be left untouched, got Start=%v End=%v", cf.Comps[1].Start, cf.Comps[1].End) } } // TestQueryCalendarObjectsOpenEndedTimeRangeRecurring is the regression // test for a real bug found while investigating the "missing recurring // events" report: a client asking for "everything from date X onward" // (RFC 4791 §9.9 explicitly permits a time-range with only a start // attribute) — which is exactly what a client's default "sync events // from N days in the past, no future limit" setting produces — decodes // with End left as the zero time.Time. go-webdav's matchCompTimeRange // then calls rrule.Set.Between(start, zero-time, true) for any recurring // event, which always returns zero occurrences (the "end" bound is year // 1, before "start"), so *every* recurring series silently vanishes from // an open-ended query, even though the exact same series matches fine // when the client happens to also send an explicit (bounded) end date. func TestQueryCalendarObjectsOpenEndedTimeRangeRecurring(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(rruleSample)) })) defer srv.Close() 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("Open db: %v", err) } defer dbase.Close() if err := dbase.CreateUser("alice", "pw", "", ""); err != nil { t.Fatalf("CreateUser: %v", err) } if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil { t.Fatalf("CreateICSSubscription: %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) b := NewBackend(&config.Config{}, st, dbase, logger, icssub.NewCache(time.Hour)) ctx := auth.NewContext(context.Background(), &auth.Principal{Username: "alice"}) // "Sync from 90 days ago, no future limit": only Start is set. gs := time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC) q := &caldav.CalendarQuery{ CompFilter: caldav.CompFilter{ Name: "VCALENDAR", Comps: []caldav.CompFilter{{ Name: "VEVENT", Start: gs, // no End: open-ended }}, }, } objs, err := b.QueryCalendarObjects(ctx, "/cal/home/holidays/", q) if err != nil { t.Fatalf("QueryCalendarObjects: %v", err) } found := false for _, co := range objs { for _, ev := range co.Data.Events() { if p := ev.Props.Get(ical.PropSummary); p != nil && p.Value == "Frontend Weekly" { found = true } } } if !found { t.Fatal("expected 'Frontend Weekly' to match an open-ended (start-only) time-range query") } }