Files

631 lines
22 KiB
Go

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(
`<?xml version="1.0"?><a:propfind xmlns:a="DAV:"><a:allprop/></a:propfind>`))
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, `<calendar-color xmlns="http://apple.com/ns/ical/">#3b82f6FF</calendar-color>`) {
t.Fatalf("expected calendar-color for work calendar, got: %s", body)
}
// The "personal" calendar has no color, so its <response> 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, "<calendar-color ") != 2 {
t.Fatalf("expected exactly two calendar-color elements (work + birthdays default), got: %s", body)
}
if !strings.Contains(body, `<calendar-color xmlns="http://apple.com/ns/ical/">`+defaultBirthdayColor+`FF</calendar-color>`) {
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(
`<?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)
}
}
// 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 <prop-filter name="DTSTART"><time-range/></prop-filter>
// *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 <calendar-query>/<prop-filter>/<time-range> 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 := `<?xml version="1.0" encoding="utf-8"?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:allprop/>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:prop-filter name="DTSTART">
<C:time-range start="20260901T000000Z" end="20260930T000000Z"/>
</C:prop-filter>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>`
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")
}
}