diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 1318a49b6..c9520e2c0 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -178,6 +178,7 @@ jobs:
test:
- feature
- web
+ - caldav
- e2e-api
exclude:
- db: sqlite
diff --git a/magefile.go b/magefile.go
index 9f4c32107..ebf0d7b3c 100644
--- a/magefile.go
+++ b/magefile.go
@@ -447,7 +447,14 @@ func (Test) Filter(ctx context.Context, filter string) error {
func (Test) All() {
mg.Deps(initVars)
- mg.Deps(Test.Feature, Test.Web, Test.E2EApi)
+ mg.Deps(Test.Feature, Test.Web, Test.Caldav, Test.E2EApi)
+}
+
+// Caldav runs the CalDAV protocol compliance tests in pkg/caldavtests.
+// These tests exercise the full HTTP router with WebDAV/CalDAV requests.
+func (Test) Caldav(ctx context.Context) error {
+ mg.Deps(initVars)
+ return runAndStreamOutput(ctx, "go", "test", goDetectVerboseFlag(), "-p", "1", "-timeout", "45m", "./pkg/caldavtests")
}
// E2EApi runs the end-to-end API tests in pkg/e2etests.
diff --git a/pkg/caldavtests/integrations.go b/pkg/caldavtests/integrations.go
new file mode 100644
index 000000000..dbf5bd445
--- /dev/null
+++ b/pkg/caldavtests/integrations.go
@@ -0,0 +1,154 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package caldavtests
+
+import (
+ "encoding/base64"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/files"
+ "code.vikunja.io/api/pkg/log"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/keyvalue"
+ "code.vikunja.io/api/pkg/routes"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/labstack/echo/v5"
+ "github.com/stretchr/testify/require"
+)
+
+// These are the test users, the same way they are in the test database
+var (
+ testuser1 = user.User{
+ ID: 1,
+ Username: "user1",
+ Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
+ Email: "user1@example.com",
+ Issuer: "local",
+ }
+ testuser15 = user.User{
+ ID: 15,
+ Username: "user15",
+ Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
+ Email: "user15@example.com",
+ Issuer: "local",
+ }
+)
+
+// fixturePassword is the plaintext password for all test fixture users
+const fixturePassword = "12345678"
+
+func setupTestEnv(t *testing.T) *echo.Echo {
+ t.Helper()
+
+ config.InitDefaultConfig()
+ config.ServicePublicURL.Set("https://localhost")
+
+ log.InitLogger()
+ files.InitTests()
+ user.InitTests()
+ models.SetupTests()
+ events.Fake()
+ keyvalue.InitStorage()
+
+ err := db.LoadFixtures()
+ require.NoError(t, err)
+
+ e := routes.NewEcho()
+ routes.RegisterRoutes(e)
+ return e
+}
+
+// basicAuthHeader returns the Authorization header value for HTTP Basic Auth.
+func basicAuthHeader(username, password string) string {
+ return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
+}
+
+// caldavRequest sends an HTTP request through the full Echo router and returns the response.
+func caldavRequest(t *testing.T, e *echo.Echo, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
+ t.Helper()
+
+ req := httptest.NewRequest(method, path, strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/xml; charset=utf-8")
+
+ // Default to testuser15 basic auth (the caldav test user) unless overridden
+ if _, hasAuth := headers["Authorization"]; !hasAuth {
+ req.Header.Set("Authorization", basicAuthHeader(testuser15.Username, fixturePassword))
+ }
+
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ return rec
+}
+
+// caldavPROPFIND sends a PROPFIND request.
+func caldavPROPFIND(t *testing.T, e *echo.Echo, path, depth, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ return caldavRequest(t, e, "PROPFIND", path, body, map[string]string{
+ "Depth": depth,
+ })
+}
+
+// caldavREPORT sends a REPORT request.
+func caldavREPORT(t *testing.T, e *echo.Echo, path, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ return caldavRequest(t, e, "REPORT", path, body, nil)
+}
+
+// caldavGET sends a GET request.
+func caldavGET(t *testing.T, e *echo.Echo, path string) *httptest.ResponseRecorder {
+ t.Helper()
+ return caldavRequest(t, e, http.MethodGet, path, "", nil)
+}
+
+// caldavPUT sends a PUT request with iCalendar content.
+func caldavPUT(t *testing.T, e *echo.Echo, path, vcalendar string) *httptest.ResponseRecorder {
+ t.Helper()
+ return caldavRequest(t, e, http.MethodPut, path, vcalendar, map[string]string{
+ "Content-Type": "text/calendar; charset=utf-8",
+ })
+}
+
+// caldavDELETE sends a DELETE request.
+func caldavDELETE(t *testing.T, e *echo.Echo, path string) *httptest.ResponseRecorder {
+ t.Helper()
+ return caldavRequest(t, e, http.MethodDelete, path, "", nil)
+}
+
+// caldavOPTIONS sends an OPTIONS request.
+func caldavOPTIONS(t *testing.T, e *echo.Echo, path string) *httptest.ResponseRecorder {
+ t.Helper()
+ return caldavRequest(t, e, http.MethodOptions, path, "", nil)
+}
+
+// caldavRequestAsUser sends a request authenticated as a specific user.
+func caldavRequestAsUser(t *testing.T, e *echo.Echo, method, path, body string, u *user.User, password string) *httptest.ResponseRecorder {
+ t.Helper()
+ return caldavRequest(t, e, method, path, body, map[string]string{
+ "Authorization": basicAuthHeader(u.Username, password),
+ })
+}
diff --git a/pkg/caldavtests/main_test.go b/pkg/caldavtests/main_test.go
new file mode 100644
index 000000000..db915f8b5
--- /dev/null
+++ b/pkg/caldavtests/main_test.go
@@ -0,0 +1,32 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package caldavtests
+
+import (
+ "flag"
+ "os"
+ "testing"
+)
+
+func TestMain(m *testing.M) {
+ flag.Parse()
+ if testing.Short() {
+ println("-short requested, skipping long-running caldav tests")
+ return
+ }
+ os.Exit(m.Run())
+}
diff --git a/pkg/caldavtests/propfind_bodies.go b/pkg/caldavtests/propfind_bodies.go
new file mode 100644
index 000000000..a0f5febb1
--- /dev/null
+++ b/pkg/caldavtests/propfind_bodies.go
@@ -0,0 +1,107 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package caldavtests
+
+// PROPFIND request bodies used by CalDAV clients.
+
+// PropfindCurrentUserPrincipal requests the current-user-principal property.
+// RFC 5397 §3
+const PropfindCurrentUserPrincipal = `
+
+
+
+
+`
+
+// PropfindCalendarHomeSet requests the calendar-home-set property.
+// RFC 4791 §6.2.1
+const PropfindCalendarHomeSet = `
+
+
+
+
+`
+
+// PropfindCalendarCollectionProperties requests common calendar collection properties.
+// RFC 4791 §5.2
+const PropfindCalendarCollectionProperties = `
+
+
+
+
+
+
+
+
+
+`
+
+// PropfindResourceProperties requests properties of a calendar resource (task).
+const PropfindResourceProperties = `
+
+
+
+
+
+`
+
+// PropfindAllProps requests all properties (allprop).
+// RFC 4918 §9.1
+const PropfindAllProps = `
+
+
+`
+
+// PropfindCurrentUserPrivilegeSet requests the current-user-privilege-set property.
+// RFC 3744 §5.4
+const PropfindCurrentUserPrivilegeSet = `
+
+
+
+
+`
+
+// ReportCalendarQuery is a calendar-query REPORT requesting all VTODOs.
+// RFC 4791 §7.8
+const ReportCalendarQuery = `
+
+
+
+
+
+
+
+
+
+
+`
+
+// ReportCalendarMultiget builds a calendar-multiget REPORT for specific hrefs.
+// RFC 4791 §7.9
+func ReportCalendarMultiget(hrefs ...string) string {
+ var hrefXML string
+ for _, href := range hrefs {
+ hrefXML += " " + href + "\n"
+ }
+ return `
+
+
+
+
+
+` + hrefXML + ``
+}
diff --git a/pkg/caldavtests/smoke_test.go b/pkg/caldavtests/smoke_test.go
new file mode 100644
index 000000000..2a1081c41
--- /dev/null
+++ b/pkg/caldavtests/smoke_test.go
@@ -0,0 +1,36 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package caldavtests
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSmoke(t *testing.T) {
+ t.Run("GET /dav/projects/36 returns VCALENDAR", func(t *testing.T) {
+ e := setupTestEnv(t)
+
+ rec := caldavGET(t, e, "/dav/projects/36")
+
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR")
+ assert.Contains(t, rec.Body.String(), "BEGIN:VTODO")
+ })
+}
diff --git a/pkg/caldavtests/vtodo_builder.go b/pkg/caldavtests/vtodo_builder.go
new file mode 100644
index 000000000..ffd57b389
--- /dev/null
+++ b/pkg/caldavtests/vtodo_builder.go
@@ -0,0 +1,200 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package caldavtests
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+// VTodoBuilder constructs VCALENDAR/VTODO strings for test requests.
+type VTodoBuilder struct {
+ uid string
+ summary string
+ description string
+ priority int
+ due time.Time
+ dtstart time.Time
+ completed time.Time
+ status string
+ categories []string
+ relatedTo []relatedToEntry
+ alarms []alarmEntry
+ rrule string
+ color string
+ percentComplete int
+ sequence int
+ duration string
+ dtstamp time.Time
+ created time.Time
+ lastMod time.Time
+ extraProps []string
+}
+
+type relatedToEntry struct {
+ reltype string // "PARENT", "CHILD", or ""
+ uid string
+}
+
+type alarmEntry struct {
+ trigger string
+ action string
+ description string
+}
+
+// NewVTodo starts building a VTODO with required fields.
+func NewVTodo(uid, summary string) *VTodoBuilder {
+ return &VTodoBuilder{
+ uid: uid,
+ summary: summary,
+ dtstamp: time.Now().UTC(),
+ created: time.Now().UTC(),
+ lastMod: time.Now().UTC(),
+ }
+}
+
+func (b *VTodoBuilder) Description(d string) *VTodoBuilder { b.description = d; return b }
+func (b *VTodoBuilder) Priority(p int) *VTodoBuilder { b.priority = p; return b }
+func (b *VTodoBuilder) Due(t time.Time) *VTodoBuilder { b.due = t; return b }
+func (b *VTodoBuilder) DtStart(t time.Time) *VTodoBuilder { b.dtstart = t; return b }
+func (b *VTodoBuilder) Completed(t time.Time) *VTodoBuilder { b.completed = t; return b }
+func (b *VTodoBuilder) Status(s string) *VTodoBuilder { b.status = s; return b }
+func (b *VTodoBuilder) Categories(c ...string) *VTodoBuilder { b.categories = c; return b }
+func (b *VTodoBuilder) Rrule(r string) *VTodoBuilder { b.rrule = r; return b }
+func (b *VTodoBuilder) Color(c string) *VTodoBuilder { b.color = c; return b }
+func (b *VTodoBuilder) Sequence(s int) *VTodoBuilder { b.sequence = s; return b }
+func (b *VTodoBuilder) Duration(d string) *VTodoBuilder { b.duration = d; return b }
+func (b *VTodoBuilder) DtStamp(t time.Time) *VTodoBuilder { b.dtstamp = t; return b }
+func (b *VTodoBuilder) Created(t time.Time) *VTodoBuilder { b.created = t; return b }
+func (b *VTodoBuilder) LastModified(t time.Time) *VTodoBuilder { b.lastMod = t; return b }
+func (b *VTodoBuilder) PercentComplete(p int) *VTodoBuilder { b.percentComplete = p; return b }
+func (b *VTodoBuilder) ExtraProp(line string) *VTodoBuilder { b.extraProps = append(b.extraProps, line); return b }
+
+func (b *VTodoBuilder) RelatedToParent(uid string) *VTodoBuilder {
+ b.relatedTo = append(b.relatedTo, relatedToEntry{reltype: "PARENT", uid: uid})
+ return b
+}
+
+func (b *VTodoBuilder) RelatedToChild(uid string) *VTodoBuilder {
+ b.relatedTo = append(b.relatedTo, relatedToEntry{reltype: "CHILD", uid: uid})
+ return b
+}
+
+func (b *VTodoBuilder) AlarmAbsolute(triggerTime time.Time) *VTodoBuilder {
+ b.alarms = append(b.alarms, alarmEntry{
+ trigger: "TRIGGER;VALUE=DATE-TIME:" + formatTime(triggerTime),
+ action: "DISPLAY",
+ description: b.summary,
+ })
+ return b
+}
+
+func (b *VTodoBuilder) AlarmRelativeStart(duration string) *VTodoBuilder {
+ b.alarms = append(b.alarms, alarmEntry{
+ trigger: "TRIGGER;RELATED=START:" + duration,
+ action: "DISPLAY",
+ description: b.summary,
+ })
+ return b
+}
+
+func (b *VTodoBuilder) AlarmRelativeEnd(duration string) *VTodoBuilder {
+ b.alarms = append(b.alarms, alarmEntry{
+ trigger: "TRIGGER;RELATED=END:" + duration,
+ action: "DISPLAY",
+ description: b.summary,
+ })
+ return b
+}
+
+func formatTime(t time.Time) string {
+ return t.UTC().Format("20060102T150405Z")
+}
+
+// Build returns the complete VCALENDAR string wrapping the VTODO.
+func (b *VTodoBuilder) Build() string {
+ var sb strings.Builder
+
+ sb.WriteString("BEGIN:VCALENDAR\r\n")
+ sb.WriteString("VERSION:2.0\r\n")
+ sb.WriteString("PRODID:-//Test//Test//EN\r\n")
+ sb.WriteString("BEGIN:VTODO\r\n")
+ sb.WriteString(fmt.Sprintf("UID:%s\r\n", b.uid))
+ sb.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", formatTime(b.dtstamp)))
+ sb.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", b.summary))
+ sb.WriteString(fmt.Sprintf("CREATED:%s\r\n", formatTime(b.created)))
+ sb.WriteString(fmt.Sprintf("LAST-MODIFIED:%s\r\n", formatTime(b.lastMod)))
+
+ if b.description != "" {
+ sb.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", b.description))
+ }
+ if b.priority > 0 {
+ sb.WriteString(fmt.Sprintf("PRIORITY:%d\r\n", b.priority))
+ }
+ if !b.due.IsZero() {
+ sb.WriteString(fmt.Sprintf("DUE:%s\r\n", formatTime(b.due)))
+ }
+ if !b.dtstart.IsZero() {
+ sb.WriteString(fmt.Sprintf("DTSTART:%s\r\n", formatTime(b.dtstart)))
+ }
+ if !b.completed.IsZero() {
+ sb.WriteString(fmt.Sprintf("COMPLETED:%s\r\n", formatTime(b.completed)))
+ }
+ if b.status != "" {
+ sb.WriteString(fmt.Sprintf("STATUS:%s\r\n", b.status))
+ }
+ if len(b.categories) > 0 {
+ sb.WriteString(fmt.Sprintf("CATEGORIES:%s\r\n", strings.Join(b.categories, ",")))
+ }
+ if b.rrule != "" {
+ sb.WriteString(fmt.Sprintf("RRULE:%s\r\n", b.rrule))
+ }
+ if b.color != "" {
+ sb.WriteString(fmt.Sprintf("X-APPLE-CALENDAR-COLOR:%s\r\n", b.color))
+ }
+ if b.percentComplete > 0 {
+ sb.WriteString(fmt.Sprintf("PERCENT-COMPLETE:%d\r\n", b.percentComplete))
+ }
+ if b.sequence > 0 {
+ sb.WriteString(fmt.Sprintf("SEQUENCE:%d\r\n", b.sequence))
+ }
+ if b.duration != "" {
+ sb.WriteString(fmt.Sprintf("DURATION:%s\r\n", b.duration))
+ }
+ for _, rel := range b.relatedTo {
+ if rel.reltype != "" {
+ sb.WriteString(fmt.Sprintf("RELATED-TO;RELTYPE=%s:%s\r\n", rel.reltype, rel.uid))
+ } else {
+ sb.WriteString(fmt.Sprintf("RELATED-TO:%s\r\n", rel.uid))
+ }
+ }
+ for _, alarm := range b.alarms {
+ sb.WriteString("BEGIN:VALARM\r\n")
+ sb.WriteString(alarm.trigger + "\r\n")
+ sb.WriteString(fmt.Sprintf("ACTION:%s\r\n", alarm.action))
+ sb.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", alarm.description))
+ sb.WriteString("END:VALARM\r\n")
+ }
+ for _, prop := range b.extraProps {
+ sb.WriteString(prop + "\r\n")
+ }
+ sb.WriteString("END:VTODO\r\n")
+ sb.WriteString("END:VCALENDAR\r\n")
+
+ return sb.String()
+}
diff --git a/pkg/caldavtests/xml_helpers.go b/pkg/caldavtests/xml_helpers.go
new file mode 100644
index 000000000..9bb405c57
--- /dev/null
+++ b/pkg/caldavtests/xml_helpers.go
@@ -0,0 +1,160 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package caldavtests
+
+import (
+ "encoding/xml"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ ics "github.com/arran4/golang-ical"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Multistatus represents a WebDAV multistatus response (RFC 4918 §13)
+type Multistatus struct {
+ XMLName xml.Name `xml:"DAV: multistatus"`
+ Responses []Response `xml:"response"`
+}
+
+// Response represents a single response within a multistatus
+type Response struct {
+ Href string `xml:"href"`
+ Propstat []Propstat `xml:"propstat"`
+}
+
+// Propstat groups a set of properties with a status
+type Propstat struct {
+ Prop Prop `xml:"prop"`
+ Status string `xml:"status"`
+}
+
+// Prop holds the actual property values returned by PROPFIND/REPORT.
+type Prop struct {
+ // Standard DAV properties
+ DisplayName string `xml:"displayname,omitempty"`
+ ResourceType RawXML `xml:"resourcetype,omitempty"`
+ GetETag string `xml:"getetag,omitempty"`
+ GetCTag string `xml:"http://calendarserver.org/ns/ getctag,omitempty"`
+
+ // CalDAV properties
+ CalendarData string `xml:"urn:ietf:params:xml:ns:caldav calendar-data,omitempty"`
+ CalendarHomeSet RawXML `xml:"urn:ietf:params:xml:ns:caldav calendar-home-set,omitempty"`
+ SupportedComponents RawXML `xml:"urn:ietf:params:xml:ns:caldav supported-calendar-component-set,omitempty"`
+ CalendarDescription string `xml:"urn:ietf:params:xml:ns:caldav calendar-description,omitempty"`
+
+ // Principal properties
+ CurrentUserPrincipal RawXML `xml:"current-user-principal,omitempty"`
+
+ // ACL properties
+ CurrentUserPrivilegeSet RawXML `xml:"current-user-privilege-set,omitempty"`
+
+ // Catch-all for unexpected properties
+ InnerXML string `xml:",innerxml"`
+}
+
+// RawXML captures raw XML content for properties we want to inspect flexibly
+type RawXML struct {
+ InnerXML string `xml:",innerxml"`
+}
+
+// parseMultistatus parses a WebDAV multistatus XML response body.
+func parseMultistatus(t *testing.T, rec *httptest.ResponseRecorder) Multistatus {
+ t.Helper()
+ var ms Multistatus
+ err := xml.Unmarshal(rec.Body.Bytes(), &ms)
+ require.NoError(t, err, "Failed to parse multistatus XML. Body:\n%s", rec.Body.String())
+ return ms
+}
+
+// findResponse finds a response in a multistatus by href substring match.
+func findResponse(t *testing.T, ms Multistatus, hrefSubstring string) Response {
+ t.Helper()
+ for _, r := range ms.Responses {
+ if strings.Contains(r.Href, hrefSubstring) {
+ return r
+ }
+ }
+ t.Fatalf("No response found with href containing %q in multistatus with %d responses", hrefSubstring, len(ms.Responses))
+ return Response{} // unreachable
+}
+
+// getSuccessfulProp returns the Prop from the first propstat with a 200 status.
+func getSuccessfulProp(t *testing.T, r Response) Prop {
+ t.Helper()
+ for _, ps := range r.Propstat {
+ if strings.Contains(ps.Status, "200") {
+ return ps.Prop
+ }
+ }
+ t.Fatalf("No successful (200) propstat found in response for href %s", r.Href)
+ return Prop{} // unreachable
+}
+
+// parseICalFromResponse parses iCalendar data from a response body.
+func parseICalFromResponse(t *testing.T, rec *httptest.ResponseRecorder) *ics.Calendar {
+ t.Helper()
+ cal, err := ics.ParseCalendar(strings.NewReader(rec.Body.String()))
+ require.NoError(t, err, "Failed to parse iCalendar. Body:\n%s", rec.Body.String())
+ return cal
+}
+
+// parseICalFromString parses iCalendar data from a string (e.g., calendar-data property).
+func parseICalFromString(t *testing.T, data string) *ics.Calendar {
+ t.Helper()
+ cal, err := ics.ParseCalendar(strings.NewReader(data))
+ require.NoError(t, err, "Failed to parse iCalendar data:\n%s", data)
+ return cal
+}
+
+// getVTodo extracts the first VTODO component from a calendar.
+func getVTodo(t *testing.T, cal *ics.Calendar) *ics.VTodo {
+ t.Helper()
+ for _, comp := range cal.Components {
+ if vtodo, ok := comp.(*ics.VTodo); ok {
+ return vtodo
+ }
+ }
+ t.Fatal("No VTODO component found in calendar")
+ return nil // unreachable
+}
+
+// getVTodoProperty extracts a property value from a VTODO.
+func getVTodoProperty(vtodo *ics.VTodo, prop ics.ComponentProperty) string {
+ p := vtodo.GetProperty(prop)
+ if p == nil {
+ return ""
+ }
+ return p.Value
+}
+
+// assertResponseStatus asserts the HTTP status code.
+func assertResponseStatus(t *testing.T, rec *httptest.ResponseRecorder, expectedStatus int) {
+ t.Helper()
+ assert.Equal(t, expectedStatus, rec.Code, "Response body:\n%s", rec.Body.String())
+}
+
+// assertMultistatusHasResponses asserts that a 207 response contains the expected number of responses.
+func assertMultistatusHasResponses(t *testing.T, rec *httptest.ResponseRecorder, expectedCount int) Multistatus {
+ t.Helper()
+ assertResponseStatus(t, rec, 207)
+ ms := parseMultistatus(t, rec)
+ assert.Len(t, ms.Responses, expectedCount, "Expected %d responses in multistatus, got %d.\nBody:\n%s", expectedCount, len(ms.Responses), rec.Body.String())
+ return ms
+}