test(caldav): add caldavtests package with infrastructure, helpers, and mage target
- Package skeleton with TestMain, setupTestEnv, and fixture users - HTTP request helpers (PROPFIND, REPORT, GET, PUT, DELETE, OPTIONS) - XML/iCal response parsers and assertion utilities - VTodoBuilder for constructing test VTODO payloads - Common PROPFIND/REPORT XML body constants - Smoke test validating the infrastructure works end-to-end - mage test:caldav command and CI matrix entry
This commit is contained in:
parent
cb4f92980b
commit
e2478e2fd6
|
|
@ -178,6 +178,7 @@ jobs:
|
|||
test:
|
||||
- feature
|
||||
- web
|
||||
- caldav
|
||||
- e2e-api
|
||||
exclude:
|
||||
- db: sqlite
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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())
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
package caldavtests
|
||||
|
||||
// PROPFIND request bodies used by CalDAV clients.
|
||||
|
||||
// PropfindCurrentUserPrincipal requests the current-user-principal property.
|
||||
// RFC 5397 §3
|
||||
const PropfindCurrentUserPrincipal = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:current-user-principal/>
|
||||
</D:prop>
|
||||
</D:propfind>`
|
||||
|
||||
// PropfindCalendarHomeSet requests the calendar-home-set property.
|
||||
// RFC 4791 §6.2.1
|
||||
const PropfindCalendarHomeSet = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<C:calendar-home-set/>
|
||||
</D:prop>
|
||||
</D:propfind>`
|
||||
|
||||
// PropfindCalendarCollectionProperties requests common calendar collection properties.
|
||||
// RFC 4791 §5.2
|
||||
const PropfindCalendarCollectionProperties = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:IC="http://apple.com/ns/ical/">
|
||||
<D:prop>
|
||||
<D:displayname/>
|
||||
<D:resourcetype/>
|
||||
<D:getetag/>
|
||||
<CS:getctag/>
|
||||
<C:supported-calendar-component-set/>
|
||||
<C:calendar-description/>
|
||||
</D:prop>
|
||||
</D:propfind>`
|
||||
|
||||
// PropfindResourceProperties requests properties of a calendar resource (task).
|
||||
const PropfindResourceProperties = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
</D:propfind>`
|
||||
|
||||
// PropfindAllProps requests all properties (allprop).
|
||||
// RFC 4918 §9.1
|
||||
const PropfindAllProps = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>`
|
||||
|
||||
// PropfindCurrentUserPrivilegeSet requests the current-user-privilege-set property.
|
||||
// RFC 3744 §5.4
|
||||
const PropfindCurrentUserPrivilegeSet = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:current-user-privilege-set/>
|
||||
</D:prop>
|
||||
</D:propfind>`
|
||||
|
||||
// ReportCalendarQuery is a calendar-query REPORT requesting all VTODOs.
|
||||
// RFC 4791 §7.8
|
||||
const ReportCalendarQuery = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VTODO"/>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>`
|
||||
|
||||
// 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 += " <D:href>" + href + "</D:href>\n"
|
||||
}
|
||||
return `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:calendar-multiget xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
` + hrefXML + `</C:calendar-multiget>`
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
||||
Loading…
Reference in New Issue