mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-11-09 00:22:38 -06:00
fix(caldav): invalidate sync tokens after permanent deletion
Implement the approved last_purged_at decision with an advancing purge generation so same-second purges invalidate old tokens and full resync tokens remain usable.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
// 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 migration
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"src.techknowlogick.com/xormigrate"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
type projects20261104142611 struct {
|
||||
LastPurgedAt time.Time `xorm:"null"`
|
||||
}
|
||||
|
||||
func (projects20261104142611) TableName() string {
|
||||
return "projects"
|
||||
}
|
||||
|
||||
func init() {
|
||||
migrations = append(migrations, &xormigrate.Migration{
|
||||
ID: "20261104142611",
|
||||
Description: "Add project purge marker for CalDAV sync tokens",
|
||||
Migrate: func(tx *xorm.Engine) error {
|
||||
return partialSync(tx, projects20261104142611{})
|
||||
},
|
||||
Rollback: func(tx *xorm.Engine) error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// 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 migration
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"xorm.io/xorm"
|
||||
"xorm.io/xorm/schemas"
|
||||
)
|
||||
|
||||
type projectsBefore20261104142611 struct {
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk"`
|
||||
OwnerID int64 `xorm:"bigint INDEX not null"`
|
||||
Title string `xorm:"varchar(250) not null"`
|
||||
}
|
||||
|
||||
func (projectsBefore20261104142611) TableName() string {
|
||||
return "projects"
|
||||
}
|
||||
|
||||
func projectTable20261104142611(t *testing.T, x *xorm.Engine) *schemas.Table {
|
||||
t.Helper()
|
||||
tables, err := x.DBMetas()
|
||||
require.NoError(t, err)
|
||||
for _, table := range tables {
|
||||
if table.Name == "projects" {
|
||||
return table
|
||||
}
|
||||
}
|
||||
t.Fatal("projects table not found")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAddProjectPurgeMarker20261104142611(t *testing.T) {
|
||||
x, err := db.CreateTestEngine()
|
||||
require.NoError(t, err)
|
||||
table := projectsBefore20261104142611{}
|
||||
require.NoError(t, x.DropTables(table))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, x.DropTables(table))
|
||||
})
|
||||
require.NoError(t, x.Sync2(table))
|
||||
existing := projectsBefore20261104142611{
|
||||
ID: 1,
|
||||
OwnerID: 42,
|
||||
Title: "Existing project",
|
||||
}
|
||||
_, err = x.Insert(&existing)
|
||||
require.NoError(t, err)
|
||||
before := projectTable20261104142611(t, x)
|
||||
require.NotEmpty(t, before.Indexes)
|
||||
for _, m := range migrations {
|
||||
if m.ID == "20261104142611" {
|
||||
require.NoError(t, m.Migrate(x))
|
||||
break
|
||||
}
|
||||
}
|
||||
after := projectTable20261104142611(t, x)
|
||||
require.NotNil(t, after.GetColumn("last_purged_at"))
|
||||
for _, column := range before.ColumnsSeq() {
|
||||
require.NotNilf(t, after.GetColumn(column), "migration dropped column %s", column)
|
||||
}
|
||||
for name, index := range before.Indexes {
|
||||
preserved, found := after.Indexes[name]
|
||||
require.Truef(t, found, "migration dropped index %s", name)
|
||||
require.Equal(t, index.Type, preserved.Type)
|
||||
require.Equal(t, index.Cols, preserved.Cols)
|
||||
}
|
||||
got := projectsBefore20261104142611{}
|
||||
found, err := x.ID(existing.ID).Get(&got)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.Equal(t, existing, got)
|
||||
}
|
||||
@@ -86,6 +86,8 @@ type Project struct {
|
||||
// A timestamp when this project was last updated. You cannot change this value.
|
||||
Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this project was last updated. You cannot change this value."`
|
||||
|
||||
LastPurgedAt time.Time `xorm:"null" json:"-"`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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 models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func advanceProjectPurgeMarker(s *xorm.Session, projectID int64) error {
|
||||
project := &Project{}
|
||||
found, err := lockingSession(s).ID(projectID).Get(project)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrProjectDoesNotExist{ID: projectID}
|
||||
}
|
||||
|
||||
marker := time.Now().UTC().Truncate(time.Second)
|
||||
// Whole seconds keep purge generations distinct across database precisions.
|
||||
if !marker.After(project.LastPurgedAt) {
|
||||
marker = project.LastPurgedAt.Add(time.Second)
|
||||
}
|
||||
_, err = s.ID(projectID).Cols("last_purged_at").NoAutoTime().Update(&Project{
|
||||
LastPurgedAt: marker,
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// 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 models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func projectPurgeMarkerForTest(t *testing.T, s *xorm.Session, projectID int64) time.Time {
|
||||
t.Helper()
|
||||
marker := struct {
|
||||
LastPurgedAt time.Time `xorm:"null"`
|
||||
}{}
|
||||
found, err := s.Table("projects").Where("id = ?", projectID).Get(&marker)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
return marker.LastPurgedAt
|
||||
}
|
||||
|
||||
func TestTaskPurgeSyncMarker(t *testing.T) {
|
||||
u := &user.User{ID: 1}
|
||||
t.Run("advances even when another purge has a newer deletion timestamp", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
future := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
_, err := s.Table("projects").Where("id = ?", 1).Update(map[string]any{
|
||||
"last_purged_at": future,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.Commit())
|
||||
s.Close()
|
||||
|
||||
require.NoError(t, deletePermanentlyForTest(t, 51, u))
|
||||
s = db.NewSession()
|
||||
defer s.Close()
|
||||
assert.True(t, projectPurgeMarkerForTest(t, s, 1).Equal(future.Add(time.Second)))
|
||||
assert.True(t, projectPurgeMarkerForTest(t, s, 2).IsZero())
|
||||
})
|
||||
|
||||
t.Run("rolls back together with the purged task", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
task := &Task{ID: 51}
|
||||
can, err := task.CanDeletePermanently(s, u)
|
||||
require.NoError(t, err)
|
||||
require.True(t, can)
|
||||
require.NoError(t, task.DeletePermanently(s, u))
|
||||
assert.False(t, projectPurgeMarkerForTest(t, s, 1).IsZero())
|
||||
require.NoError(t, s.Rollback())
|
||||
s.Close()
|
||||
|
||||
s = db.NewSession()
|
||||
defer s.Close()
|
||||
assert.True(t, projectPurgeMarkerForTest(t, s, 1).IsZero())
|
||||
db.AssertExists(t, "tasks", map[string]any{
|
||||
"id": 51,
|
||||
}, false)
|
||||
})
|
||||
|
||||
t.Run("retention cleanup keeps the marker unchanged", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
deleteExpiredTasks(time.Now())
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
assert.True(t, projectPurgeMarkerForTest(t, s, 1).IsZero())
|
||||
})
|
||||
}
|
||||
@@ -146,6 +146,11 @@ func purgeTrashedTask(s *xorm.Session, t *Task, doer *user.User) error {
|
||||
if err := hardDeleteTask(s, t); err != nil {
|
||||
return err
|
||||
}
|
||||
if doer != nil {
|
||||
if err := advanceProjectPurgeMarker(s, t.ProjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
events.DispatchOnCommit(s, &TaskPurgedEvent{
|
||||
Task: t,
|
||||
Doer: doer,
|
||||
|
||||
@@ -1144,7 +1144,11 @@ func (vlra *VikunjaProjectResourceAdapter) CalculateEtag() string {
|
||||
}
|
||||
}
|
||||
|
||||
return `"` + strconv.FormatInt(vlra.project.ID, 10) + `-` + strconv.FormatInt(latest.Unix(), 10) + `"`
|
||||
etag := strconv.FormatInt(vlra.project.ID, 10) + "-" + strconv.FormatInt(latest.Unix(), 10)
|
||||
if !vlra.project.LastPurgedAt.IsZero() {
|
||||
etag += ";purge=" + strconv.FormatInt(vlra.project.LastPurgedAt.Unix(), 10)
|
||||
}
|
||||
return `"` + etag + `"`
|
||||
}
|
||||
|
||||
// GetContent returns the content string of a resource (a task in our case)
|
||||
|
||||
@@ -37,9 +37,7 @@ const (
|
||||
syncTokenPrefix = "data:,"
|
||||
)
|
||||
|
||||
// parseSyncToken extracts the project ID and timestamp from a sync token
|
||||
// of the form data:,"PROJECT_ID-UNIX_TIMESTAMP".
|
||||
func parseSyncToken(token string) (projectID int64, ts time.Time, ok bool) {
|
||||
func parseSyncToken(token string) (projectID int64, ts time.Time, purgeGeneration int64, ok bool) {
|
||||
// Quotes may sit outside or inside the data:, prefix.
|
||||
token = strings.TrimPrefix(token, `"`)
|
||||
token = strings.TrimSuffix(token, `"`)
|
||||
@@ -47,14 +45,23 @@ func parseSyncToken(token string) (projectID int64, ts time.Time, ok bool) {
|
||||
token = strings.TrimPrefix(token, `"`)
|
||||
token = strings.TrimSuffix(token, `"`)
|
||||
|
||||
if timestamp, generation, found := strings.Cut(token, ";purge="); found {
|
||||
var err error
|
||||
purgeGeneration, err = strconv.ParseInt(generation, 10, 64)
|
||||
if err != nil || purgeGeneration <= 0 {
|
||||
return 0, time.Time{}, 0, false
|
||||
}
|
||||
token = timestamp
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return 0, time.Time{}, false
|
||||
return 0, time.Time{}, 0, false
|
||||
}
|
||||
|
||||
// Split on the last '-' so a negative project ID can't break parsing.
|
||||
idx := strings.LastIndex(token, "-")
|
||||
if idx < 0 {
|
||||
return 0, time.Time{}, false
|
||||
return 0, time.Time{}, 0, false
|
||||
}
|
||||
|
||||
projectIDStr := token[:idx]
|
||||
@@ -62,15 +69,15 @@ func parseSyncToken(token string) (projectID int64, ts time.Time, ok bool) {
|
||||
|
||||
pid, err := strconv.ParseInt(projectIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return 0, time.Time{}, false
|
||||
return 0, time.Time{}, 0, false
|
||||
}
|
||||
|
||||
unixSec, err := strconv.ParseInt(tsStr, 10, 64)
|
||||
if err != nil {
|
||||
return 0, time.Time{}, false
|
||||
return 0, time.Time{}, 0, false
|
||||
}
|
||||
|
||||
return pid, time.Unix(unixSec, 0).UTC(), true
|
||||
return pid, time.Unix(unixSec, 0).UTC(), purgeGeneration, true
|
||||
}
|
||||
|
||||
// extractSyncTokenFromBody pulls the <D:sync-token> text content from the
|
||||
@@ -152,7 +159,6 @@ func handleSyncCollectionReport(c *echo.Context, body string, storage *VikunjaCa
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "internal server error").Wrap(err)
|
||||
}
|
||||
|
||||
// The etag is "PROJECT_ID-UNIX_TS"; the new sync token reuses it.
|
||||
newEtag := rr.CalculateEtag()
|
||||
newEtagClean := strings.Trim(newEtag, `"`)
|
||||
newToken := syncTokenPrefix + `"` + newEtagClean + `"`
|
||||
@@ -164,7 +170,7 @@ func handleSyncCollectionReport(c *echo.Context, body string, storage *VikunjaCa
|
||||
return writeSyncResponse(c, rr, rr.projectTasks, nil, newToken, includeCalendarData)
|
||||
}
|
||||
|
||||
tokenProjectID, tokenTS, ok := parseSyncToken(rawToken)
|
||||
tokenProjectID, tokenTS, tokenPurgeGeneration, ok := parseSyncToken(rawToken)
|
||||
if !ok {
|
||||
log.Warningf("[CALDAV sync-collection] project=%d could not parse token %q → 403 valid-sync-token", projectID, rawToken)
|
||||
return writeForbiddenValidSyncToken(c)
|
||||
@@ -175,6 +181,14 @@ func handleSyncCollectionReport(c *echo.Context, body string, storage *VikunjaCa
|
||||
return writeForbiddenValidSyncToken(c)
|
||||
}
|
||||
|
||||
purgeGeneration := int64(0)
|
||||
if !rr.project.LastPurgedAt.IsZero() {
|
||||
purgeGeneration = rr.project.LastPurgedAt.Unix()
|
||||
}
|
||||
if tokenPurgeGeneration != purgeGeneration {
|
||||
return writeForbiddenValidSyncToken(c)
|
||||
}
|
||||
|
||||
// Tokens older than the soft-delete retention window cannot see deletions
|
||||
// whose tasks were already purged — force a full resync instead of
|
||||
// silently serving an incomplete delta.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// 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 caldav
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSyncTokenPurgeGeneration(t *testing.T) {
|
||||
updated := time.Unix(1_700_000_000, 0).UTC()
|
||||
purged := updated.Add(time.Hour)
|
||||
rr := VikunjaProjectResourceAdapter{
|
||||
project: &models.ProjectWithTasksAndBuckets{
|
||||
Project: models.Project{
|
||||
ID: 36,
|
||||
Updated: updated,
|
||||
LastPurgedAt: purged,
|
||||
},
|
||||
},
|
||||
}
|
||||
token := syncTokenPrefix + rr.CalculateEtag()
|
||||
projectID, timestamp, generation, ok := parseSyncToken(token)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, int64(36), projectID)
|
||||
assert.Equal(t, updated, timestamp)
|
||||
assert.Equal(t, purged.Unix(), generation)
|
||||
|
||||
_, _, generation, ok = parseSyncToken(`data:,"36-1700000000"`)
|
||||
require.True(t, ok)
|
||||
assert.Zero(t, generation)
|
||||
|
||||
for _, token := range []string{
|
||||
`data:,"36-1700000000;purge=abc"`,
|
||||
`data:,"36-1700000000;purge=0"`,
|
||||
`data:,"36-1700000000;purge=-1"`,
|
||||
} {
|
||||
_, _, _, ok := parseSyncToken(token)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
}
|
||||
@@ -1468,6 +1468,102 @@ END:VCALENDAR`
|
||||
})
|
||||
}
|
||||
|
||||
func TestCaldavSyncCollectionAfterPurge(t *testing.T) {
|
||||
deletedAt := time.Now().UTC().Add(-time.Hour).Truncate(time.Second)
|
||||
purge := func(t *testing.T, taskID int64) {
|
||||
t.Helper()
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
_, err := s.Unscoped().ID(taskID).Cols("deleted_at").NoAutoTime().Update(&models.Task{
|
||||
DeletedAt: deletedAt,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
task := &models.Task{ID: taskID}
|
||||
can, err := task.CanDeletePermanently(s, &testuser15)
|
||||
require.NoError(t, err)
|
||||
require.True(t, can)
|
||||
require.NoError(t, task.DeletePermanently(s, &testuser15))
|
||||
_, err = s.ID(int64(36)).Cols("updated").NoAutoTime().Update(&models.Project{
|
||||
Updated: deletedAt,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.Commit())
|
||||
}
|
||||
syncReport := func(t *testing.T, e *echo.Echo, token string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body := `<D:sync-collection xmlns:D="DAV:"><D:sync-token>` + token +
|
||||
`</D:sync-token><D:sync-level>1</D:sync-level><D:prop><D:getetag/></D:prop></D:sync-collection>`
|
||||
rec, err := newCaldavTestRequestWithUser(
|
||||
t, e, "REPORT", caldav.ProjectHandler, &testuser15, body, nil,
|
||||
map[string]string{
|
||||
"project": "36",
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
return rec
|
||||
}
|
||||
fullSync := func(t *testing.T, e *echo.Echo) string {
|
||||
t.Helper()
|
||||
rec := syncReport(t, e, "")
|
||||
require.Equal(t, http.StatusMultiStatus, rec.Code)
|
||||
response := struct {
|
||||
SyncToken string `xml:"sync-token"`
|
||||
}{}
|
||||
require.NoError(t, xml.Unmarshal(rec.Body.Bytes(), &response))
|
||||
require.NotEmpty(t, response.SyncToken)
|
||||
assert.NotContains(t, rec.Body.String(), "uid-caldav-test.ics")
|
||||
return response.SyncToken
|
||||
}
|
||||
|
||||
t.Run("purge invalidates older and same-second tokens", func(t *testing.T) {
|
||||
e, _ := setupTestEnv()
|
||||
purge(t, 40)
|
||||
for _, offset := range []time.Duration{
|
||||
-time.Second,
|
||||
0,
|
||||
} {
|
||||
token := fmt.Sprintf(`data:,"36-%d"`, deletedAt.Add(offset).Unix())
|
||||
rec := syncReport(t, e, token)
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "valid-sync-token")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("full resync produces a usable token", func(t *testing.T) {
|
||||
e, _ := setupTestEnv()
|
||||
purge(t, 40)
|
||||
token := fullSync(t, e)
|
||||
rec := syncReport(t, e, token)
|
||||
assert.Equal(t, http.StatusMultiStatus, rec.Code)
|
||||
})
|
||||
|
||||
t.Run("purge generation leaves same-second live changes visible", func(t *testing.T) {
|
||||
e, _ := setupTestEnv()
|
||||
purge(t, 40)
|
||||
token := fullSync(t, e)
|
||||
s := db.NewSession()
|
||||
_, err := s.ID(int64(41)).Cols("updated").NoAutoTime().Update(&models.Task{
|
||||
Updated: deletedAt,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.Commit())
|
||||
s.Close()
|
||||
rec := syncReport(t, e, token)
|
||||
assert.Equal(t, http.StatusMultiStatus, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "uid-caldav-test-parent-task.ics")
|
||||
})
|
||||
|
||||
t.Run("a second purge in the same second invalidates the resync token", func(t *testing.T) {
|
||||
e, _ := setupTestEnv()
|
||||
purge(t, 40)
|
||||
token := fullSync(t, e)
|
||||
purge(t, 41)
|
||||
rec := syncReport(t, e, token)
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "valid-sync-token")
|
||||
})
|
||||
}
|
||||
|
||||
// TestCaldavPartialUpdate: properties a CalDAV PUT doesn't carry must survive the update.
|
||||
func TestCaldavPartialUpdate(t *testing.T) {
|
||||
const vtodoHeader = `BEGIN:VCALENDAR
|
||||
|
||||
Reference in New Issue
Block a user