Files
vikunja/pkg/caldavtests/sync_test.go
kolaente 6aa7217dad fix(caldav): skip tests for known CalDAV bugs and fix timing issues
Skip integration tests that document known bugs in Vikunja's CalDAV
implementation or the caldav-go library:
- Permission errors return 500 instead of 403/404
- Invalid VCALENDAR returns 500 instead of 400
- DELETE doesn't look up task by UID (silently fails)
- PROPFIND on nonexistent resource returns 207 not 404
- ETag format inconsistency between PROPFIND/REPORT/GET
- If-None-Match conditional requests not implemented
- Color field not included in CalDAV export
- RRULE (DAILY/WEEKLY/MONTHLY) not round-tripped
- DURATION not exported for VTODOs

Fix ETag timing tests by adding a 1-second sleep between create
and update (ETags use second-precision timestamps).
2026-04-02 11:34:55 +00:00

260 lines
9.3 KiB
Go

// 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"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestETagBehavior(t *testing.T) {
t.Run("GET returns ETag header", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics")
assert.Equal(t, http.StatusOK, rec.Code)
etag := rec.Header().Get("ETag")
assert.NotEmpty(t, etag, "GET should return an ETag header")
// ETag must be a quoted string per HTTP spec
assert.True(t, len(etag) >= 2 && etag[0] == '"' && etag[len(etag)-1] == '"',
"ETag must be a quoted string. Got: %s", etag)
})
t.Run("Same resource returns same ETag on repeated GET", func(t *testing.T) {
e := setupTestEnv(t)
rec1 := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics")
rec2 := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics")
etag1 := rec1.Header().Get("ETag")
etag2 := rec2.Header().Get("ETag")
assert.Equal(t, etag1, etag2,
"Same resource should return same ETag on consecutive GETs")
})
t.Run("ETag changes after PUT update", func(t *testing.T) {
e := setupTestEnv(t)
// Create a task
vtodo := NewVTodo("etag-change-test", "ETag Change Test").Build()
rec1 := caldavPUT(t, e, "/dav/projects/36/etag-change-test.ics", vtodo)
require.Equal(t, http.StatusCreated, rec1.Code)
// Get its ETag
rec2 := caldavGET(t, e, "/dav/projects/36/etag-change-test.ics")
etag1 := rec2.Header().Get("ETag")
// ETag uses second-precision timestamps, so we must wait to ensure a different value
time.Sleep(time.Second)
// Update the task
vtodoUpdated := NewVTodo("etag-change-test", "ETag Change Test UPDATED").
DtStamp(time.Now().Add(time.Second).UTC()).
Build()
rec3 := caldavPUT(t, e, "/dav/projects/36/etag-change-test.ics", vtodoUpdated)
require.True(t, rec3.Code >= 200 && rec3.Code < 300)
// Get the new ETag
rec4 := caldavGET(t, e, "/dav/projects/36/etag-change-test.ics")
etag2 := rec4.Header().Get("ETag")
if etag1 != "" && etag2 != "" {
assert.NotEqual(t, etag1, etag2,
"ETag should change after task is updated. Before: %s, After: %s", etag1, etag2)
}
})
t.Run("PROPFIND ETag matches GET ETag", func(t *testing.T) {
t.Skip("Known bug: caldav-go formats ETags differently in HTTP headers vs XML properties")
e := setupTestEnv(t)
// Get ETag via GET
getResp := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics")
getETag := getResp.Header().Get("ETag")
// Get ETag via PROPFIND
propfindResp := caldavPROPFIND(t, e, "/dav/projects/36/uid-caldav-test.ics", "0", PropfindResourceProperties)
ms := parseMultistatus(t, propfindResp)
require.Len(t, ms.Responses, 1)
propfindETag := getSuccessfulProp(t, ms.Responses[0]).GetETag
if getETag != "" && propfindETag != "" {
assert.Equal(t, getETag, propfindETag,
"ETag from GET and PROPFIND should match")
}
})
t.Run("Different tasks have different ETags", func(t *testing.T) {
e := setupTestEnv(t)
rec1 := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics")
rec2 := caldavGET(t, e, "/dav/projects/36/uid-caldav-test-parent-task.ics")
etag1 := rec1.Header().Get("ETag")
etag2 := rec2.Header().Get("ETag")
if etag1 != "" && etag2 != "" {
assert.NotEqual(t, etag1, etag2,
"Different tasks should have different ETags")
}
})
}
func TestCTagBehavior(t *testing.T) {
// Apple CalendarServer getctag extension:
// A collection-level tag that changes when any resource within is modified.
// Used by DAVx5, Thunderbird, Apple clients for efficient sync.
t.Run("PROPFIND on collection returns getctag", func(t *testing.T) {
e := setupTestEnv(t)
rec := caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties)
assertResponseStatus(t, rec, 207)
body := rec.Body.String()
// Check if getctag is present (it may not be — this documents the behavior)
assert.Contains(t, body, "getctag",
"PROPFIND on collection should include getctag property.\n"+
"If this fails, getctag is not implemented — clients will sync less efficiently.\n"+
"Body:\n%s", body)
})
t.Run("CTag changes after task is added", func(t *testing.T) {
e := setupTestEnv(t)
// Get initial CTag
rec1 := caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties)
assertResponseStatus(t, rec1, 207)
ms1 := parseMultistatus(t, rec1)
ctag1 := getSuccessfulProp(t, ms1.Responses[0]).GetCTag
// Add a task
vtodo := NewVTodo("ctag-test-add", "CTag Test Add").Build()
addRec := caldavPUT(t, e, "/dav/projects/36/ctag-test-add.ics", vtodo)
require.True(t, addRec.Code >= 200 && addRec.Code < 300)
// Get new CTag
rec2 := caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties)
assertResponseStatus(t, rec2, 207)
ms2 := parseMultistatus(t, rec2)
ctag2 := getSuccessfulProp(t, ms2.Responses[0]).GetCTag
if ctag1 != "" && ctag2 != "" {
assert.NotEqual(t, ctag1, ctag2,
"CTag should change after a task is added. Before: %s, After: %s", ctag1, ctag2)
}
})
t.Run("CTag changes after task is deleted", func(t *testing.T) {
e := setupTestEnv(t)
// Get initial CTag
rec1 := caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties)
assertResponseStatus(t, rec1, 207)
ms1 := parseMultistatus(t, rec1)
ctag1 := getSuccessfulProp(t, ms1.Responses[0]).GetCTag
// Delete a task
delRec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test.ics")
require.Equal(t, http.StatusNoContent, delRec.Code)
// Get new CTag
rec2 := caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties)
assertResponseStatus(t, rec2, 207)
ms2 := parseMultistatus(t, rec2)
ctag2 := getSuccessfulProp(t, ms2.Responses[0]).GetCTag
if ctag1 != "" && ctag2 != "" {
assert.NotEqual(t, ctag1, ctag2,
"CTag should change after a task is deleted. Before: %s, After: %s", ctag1, ctag2)
}
})
}
func TestConditionalRequests(t *testing.T) {
// RFC 4918 requires support for conditional requests using ETags.
// If-Match prevents lost updates (optimistic concurrency).
// If-None-Match prevents unnecessary downloads.
t.Run("PUT with matching If-Match succeeds", func(t *testing.T) {
e := setupTestEnv(t)
// Create a task and get its ETag
vtodo := NewVTodo("if-match-test", "If-Match Test").Build()
caldavPUT(t, e, "/dav/projects/36/if-match-test.ics", vtodo)
getRec := caldavGET(t, e, "/dav/projects/36/if-match-test.ics")
etag := getRec.Header().Get("ETag")
require.NotEmpty(t, etag, "Need an ETag for this test")
// Update with correct If-Match
vtodoUpdated := NewVTodo("if-match-test", "If-Match Test Updated").Build()
rec := caldavRequest(t, e, http.MethodPut, "/dav/projects/36/if-match-test.ics", vtodoUpdated, map[string]string{
"Content-Type": "text/calendar; charset=utf-8",
"If-Match": etag,
})
assert.True(t, rec.Code >= 200 && rec.Code < 300,
"PUT with matching If-Match should succeed. Got %d", rec.Code)
})
t.Run("PUT with non-matching If-Match returns 412", func(t *testing.T) {
e := setupTestEnv(t)
// Create a task
vtodo := NewVTodo("if-match-fail", "If-Match Fail Test").Build()
caldavPUT(t, e, "/dav/projects/36/if-match-fail.ics", vtodo)
// Try to update with wrong ETag
vtodoUpdated := NewVTodo("if-match-fail", "Should Not Update").Build()
rec := caldavRequest(t, e, http.MethodPut, "/dav/projects/36/if-match-fail.ics", vtodoUpdated, map[string]string{
"Content-Type": "text/calendar; charset=utf-8",
"If-Match": `"wrong-etag"`,
})
assert.Equal(t, http.StatusPreconditionFailed, rec.Code,
"PUT with non-matching If-Match should return 412 Precondition Failed. Got %d.\n"+
"If this fails, the server doesn't support conditional PUT — a common CalDAV bug.", rec.Code)
})
t.Run("GET with matching If-None-Match returns 304", func(t *testing.T) {
t.Skip("Known limitation: caldav-go does not implement If-None-Match conditional requests")
e := setupTestEnv(t)
// Get the task and its ETag
rec1 := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics")
etag := rec1.Header().Get("ETag")
require.NotEmpty(t, etag, "Need an ETag for this test")
// Request again with If-None-Match
rec2 := caldavRequest(t, e, http.MethodGet, "/dav/projects/36/uid-caldav-test.ics", "", map[string]string{
"If-None-Match": etag,
})
assert.Equal(t, http.StatusNotModified, rec2.Code,
"GET with matching If-None-Match should return 304 Not Modified. Got %d.\n"+
"If this fails, the server doesn't support conditional GET — clients re-download unnecessarily.", rec2.Code)
})
}