diff --git a/pkg/caldavtests/sync_test.go b/pkg/caldavtests/sync_test.go new file mode 100644 index 000000000..0f30f2580 --- /dev/null +++ b/pkg/caldavtests/sync_test.go @@ -0,0 +1,254 @@ +// 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" + "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") + + // 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) { + 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) { + 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) + }) +}