fix(caldav): add tags and sync token to collections (#2482)

Fixes #2401
This commit is contained in:
surfingbytes 2026-03-26 11:42:39 +01:00 committed by GitHub
parent 9d8c6a0a72
commit 8e8ffac016
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 144 additions and 16 deletions

2
go.mod
View File

@ -208,4 +208,4 @@ tool (
src.techknowlogick.com/xgo
)
replace github.com/samedi/caldav-go => github.com/kolaente/caldav-go v3.0.1-0.20190610114120-2a4eb8b5dcc9+incompatible // Branch: feature/dynamic-supported-components, PR: https://github.com/samedi/caldav-go/pull/6 and https://github.com/samedi/caldav-go/pull/7
replace github.com/samedi/caldav-go => github.com/kolaente/caldav-go v3.0.1-0.20260326091743-a55d55891017+incompatible

4
go.sum
View File

@ -328,8 +328,8 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:C
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kolaente/caldav-go v3.0.1-0.20190610114120-2a4eb8b5dcc9+incompatible h1:q7DbyV+sFjEoTuuUdRDNl2nlyfztkZgxVVCV7JhzIkY=
github.com/kolaente/caldav-go v3.0.1-0.20190610114120-2a4eb8b5dcc9+incompatible/go.mod h1:y1UhTNI4g0hVymJrI6yJ5/ohy09hNBeU8iJEZjgdDOw=
github.com/kolaente/caldav-go v3.0.1-0.20260326091743-a55d55891017+incompatible h1:81Hr6g9bunxXhRv4AZv0anKcS1WwHLMgo6wbBjamJlY=
github.com/kolaente/caldav-go v3.0.1-0.20260326091743-a55d55891017+incompatible/go.mod h1:y1UhTNI4g0hVymJrI6yJ5/ohy09hNBeU8iJEZjgdDOw=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=

View File

@ -662,15 +662,6 @@ func (vlra *VikunjaProjectResourceAdapter) IsCollection() bool {
// CalculateEtag returns the etag of a resource
func (vlra *VikunjaProjectResourceAdapter) CalculateEtag() string {
// If we're updating a task, the client sends the etag of the project instead of the one from the task.
// And therefore, updating the task fails since these etags don't match.
// To fix that, we use this extra field to determine if we're currently updating a task and return the
// etag of the project instead.
// if vlra.project != nil {
// return `"` + strconv.FormatInt(vlra.project.ID, 10) + `-` + strconv.FormatInt(vlra.project.Updated, 10) + `"`
// }
// Return the etag of a task if we have one
if vlra.task != nil {
return `"` + strconv.FormatInt(vlra.task.ID, 10) + `-` + strconv.FormatInt(vlra.task.Updated.Unix(), 10) + `"`
}
@ -679,10 +670,17 @@ func (vlra *VikunjaProjectResourceAdapter) CalculateEtag() string {
return ""
}
// This also returns the etag of the project, and not of the task,
// which becomes problematic because the client uses this etag (= the one from the project) to make
// Requests to update a task. These do not match and thus updating a task fails.
return `"` + strconv.FormatInt(vlra.project.ID, 10) + `-` + strconv.FormatInt(vlra.project.Updated.Unix(), 10) + `"`
// For collections, use the latest modification time across all tasks
// so that the etag (and derived ctag/sync-token) changes whenever
// any task in the project is added, modified, or deleted.
latest := vlra.project.Updated
for _, t := range vlra.projectTasks {
if t.Updated.After(latest) {
latest = t.Updated
}
}
return `"` + strconv.FormatInt(vlra.project.ID, 10) + `-` + strconv.FormatInt(latest.Unix(), 10) + `"`
}
// GetContent returns the content string of a resource (a task in our case)

View File

@ -662,6 +662,136 @@ END:VCALENDAR`
})
}
func TestCaldavCollectionProperties(t *testing.T) {
t.Run("PROPFIND returns getetag, getctag, and sync-token for collections", func(t *testing.T) {
e, _ := setupTestEnv()
propfindBody := `<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:" xmlns:CS="http://calendarserver.org/ns/">
<D:prop>
<D:getetag/>
<CS:getctag/>
<D:sync-token/>
<D:displayname/>
</D:prop>
</D:propfind>`
rec, err := newCaldavTestRequestWithUser(t, e, "PROPFIND", caldav.ProjectHandler, &testuser15, propfindBody,
nil, map[string]string{"project": "36"})
require.NoError(t, err)
assert.Equal(t, 207, rec.Result().StatusCode)
responseBody := rec.Body.String()
type Propstat struct {
Prop struct {
Getetag string `xml:"getetag"`
Getctag string `xml:"http://calendarserver.org/ns/ getctag"`
SyncToken string `xml:"sync-token"`
Displayname string `xml:"displayname"`
} `xml:"prop"`
Status string `xml:"status"`
}
type Response struct {
Href string `xml:"href"`
Propstats []Propstat `xml:"propstat"`
}
type Multistatus struct {
Responses []Response `xml:"response"`
}
var multistatus Multistatus
err = xml.Unmarshal([]byte(responseBody), &multistatus)
require.NoError(t, err)
require.NotEmpty(t, multistatus.Responses, "Should have at least one response")
collectionResp := multistatus.Responses[0]
var foundEtag, foundCtag, foundSyncToken bool
for _, ps := range collectionResp.Propstats {
if strings.Contains(ps.Status, "200") {
if ps.Prop.Getetag != "" {
foundEtag = true
assert.Contains(t, ps.Prop.Getetag, "36-", "Collection etag should contain the project ID")
}
if ps.Prop.Getctag != "" {
foundCtag = true
assert.Contains(t, ps.Prop.Getctag, "36-", "Collection ctag should contain the project ID")
}
if ps.Prop.SyncToken != "" {
foundSyncToken = true
assert.Contains(t, ps.Prop.SyncToken, "data:,", "Sync token should be a data: URI")
}
}
}
assert.True(t, foundEtag, "Collection should have getetag")
assert.True(t, foundCtag, "Collection should have getctag")
assert.True(t, foundSyncToken, "Collection should have sync-token")
})
t.Run("Each collection has a unique etag", func(t *testing.T) {
e, _ := setupTestEnv()
propfindBody := `<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:getetag/>
</D:prop>
</D:propfind>`
rec1, err := newCaldavTestRequestWithUser(t, e, "PROPFIND", caldav.ProjectHandler, &testuser15, propfindBody,
nil, map[string]string{"project": "36"})
require.NoError(t, err)
assert.Equal(t, 207, rec1.Result().StatusCode)
rec2, err := newCaldavTestRequestWithUser(t, e, "PROPFIND", caldav.ProjectHandler, &testuser15, propfindBody,
nil, map[string]string{"project": "38"})
require.NoError(t, err)
assert.Equal(t, 207, rec2.Result().StatusCode)
type Propstat struct {
Prop struct {
Getetag string `xml:"getetag"`
} `xml:"prop"`
Status string `xml:"status"`
}
type Response struct {
Href string `xml:"href"`
Propstats []Propstat `xml:"propstat"`
}
type Multistatus struct {
Responses []Response `xml:"response"`
}
var ms1, ms2 Multistatus
err = xml.Unmarshal(rec1.Body.Bytes(), &ms1)
require.NoError(t, err)
err = xml.Unmarshal(rec2.Body.Bytes(), &ms2)
require.NoError(t, err)
require.NotEmpty(t, ms1.Responses)
require.NotEmpty(t, ms2.Responses)
var etag1, etag2 string
for _, ps := range ms1.Responses[0].Propstats {
if strings.Contains(ps.Status, "200") && ps.Prop.Getetag != "" {
etag1 = ps.Prop.Getetag
}
}
for _, ps := range ms2.Responses[0].Propstats {
if strings.Contains(ps.Status, "200") && ps.Prop.Getetag != "" {
etag2 = ps.Prop.Getetag
}
}
assert.NotEmpty(t, etag1, "Project 36 should have an etag")
assert.NotEmpty(t, etag2, "Project 38 should have an etag")
assert.NotEqual(t, etag1, etag2, "Different projects should have different etags")
})
}
func TestCaldavProjectReport(t *testing.T) {
t.Run("REPORT calendar-query returns all tasks", func(t *testing.T) {
e, _ := setupTestEnv()