Files
vikunja/pkg/models/task_trash_test.go
kolaente bab08e90c7 fix(tasks): lock views before tasks and recheck restores
Address PR Swarm lock-order decision across task writes, restoration, and project/user purges. Reject moves discovered after view locks without acquiring views out of order.
2026-11-05 11:29:33 +01:00

833 lines
25 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 models
import (
"context"
"maps"
"slices"
"testing"
"time"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/license"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/web"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm"
"xorm.io/xorm/schemas"
)
func trashTaskForTest(t *testing.T, taskID int64, a web.Auth) {
s := db.NewSession()
defer s.Close()
require.NoError(t, (&Task{ID: taskID}).Delete(s, a))
require.NoError(t, s.Commit())
}
func setTaskColumnsForTest(t *testing.T, taskID int64, cols map[string]any) {
s := db.NewAutocommitSession()
defer s.Close()
_, err := s.Table("tasks").Where("id = ?", taskID).Update(cols)
require.NoError(t, err)
}
func restoreTaskForTest(t *testing.T, taskID int64, a web.Auth) (*Task, error) {
s := db.NewSession()
defer s.Close()
task := &Task{ID: taskID}
can, err := task.CanRestore(s, a)
if err != nil {
return nil, err
}
if !can {
return nil, ErrGenericForbidden{}
}
if err := task.Restore(s, a); err != nil {
return nil, err
}
require.NoError(t, s.Commit())
events.DispatchPending(context.Background(), s)
return task, nil
}
func deletePermanentlyForTest(t *testing.T, taskID int64, a web.Auth) error {
s := db.NewSession()
defer s.Close()
task := &Task{ID: taskID}
can, err := task.CanDeletePermanently(s, a)
if err != nil {
return err
}
if !can {
return ErrGenericForbidden{}
}
require.NoError(t, task.DeletePermanently(s, a))
require.NoError(t, s.Commit())
events.DispatchPending(context.Background(), s)
return nil
}
func purgedTaskDoers(t *testing.T) map[int64]*user.User {
t.Helper()
doers := map[int64]*user.User{}
for _, e := range events.GetDispatchedEvents((&TaskPurgedEvent{}).Name()) {
purged, ok := e.(*TaskPurgedEvent)
require.True(t, ok)
doers[purged.Task.ID] = purged.Doer
}
return doers
}
func TestTaskRestoreRejectsMoveAfterPermissionCheck(t *testing.T) {
if db.Type() != schemas.MYSQL && db.Type() != schemas.POSTGRES {
t.Skip("Requires concurrent database transactions")
}
for _, canWriteTarget := range []bool{
false,
true,
} {
t.Run(map[bool]string{
false: "target is unreadable",
true: "target remains writable",
}[canWriteTarget], func(t *testing.T) {
db.LoadAndAssertFixtures(t)
setup := db.NewSession()
_, err := setup.Insert(&ProjectUser{
UserID: 3,
ProjectID: 1,
Permission: PermissionWrite,
})
require.NoError(t, err)
if canWriteTarget {
_, err = setup.Insert(&ProjectUser{
UserID: 1,
ProjectID: 2,
Permission: PermissionWrite,
})
require.NoError(t, err)
}
require.NoError(t, setup.Commit())
_ = setup.Close()
restorer := db.NewSession()
defer restorer.Close()
defer events.CleanupPending(restorer)
task := &Task{ID: 51}
can, err := task.CanRestore(restorer, &user.User{ID: 1})
require.NoError(t, err)
require.True(t, can)
mover := db.NewSession()
defer mover.Close()
defer events.CleanupPending(mover)
other := &Task{ID: 51}
u3 := &user.User{ID: 3}
can, err = other.CanRestore(mover, u3)
require.NoError(t, err)
require.True(t, can)
require.NoError(t, other.Restore(mover, u3))
require.NoError(t, (&Task{
ID: 51,
Title: "moved while restoring",
ProjectID: 2,
}).Update(mover, u3))
require.NoError(t, other.Delete(mover, u3))
require.NoError(t, mover.Commit())
err = task.Restore(restorer, &user.User{ID: 1})
if canWriteTarget {
require.ErrorIs(t, err, ErrGenericForbidden{})
} else {
require.True(t, IsErrTaskDoesNotExist(err), "got %v", err)
}
require.NoError(t, restorer.Rollback())
db.AssertExists(t, "tasks", map[string]interface{}{
"id": 51,
"project_id": 2,
}, false)
db.AssertMissing(t, "tasks", map[string]interface{}{
"id": 51,
"deleted_at": nil,
})
db.AssertMissing(t, "task_positions", map[string]interface{}{"task_id": 51})
})
}
}
func TestTask_Restore(t *testing.T) {
u1 := &user.User{ID: 1}
t.Run("restores into the default bucket of every view", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
task, err := restoreTaskForTest(t, 51, u1)
require.NoError(t, err)
events.AssertDispatched(t, &TaskRestoredEvent{})
db.AssertExists(t, "tasks", map[string]interface{}{
"id": 51,
"deleted_at": nil,
"deleted_by_id": nil,
}, false)
assert.True(t, task.Updated.After(time.Date(2018, 12, 1, 1, 12, 4, 0, time.UTC)))
// View 4 is project 1's manual kanban with default bucket 1
db.AssertExists(t, "task_positions", map[string]interface{}{
"task_id": 51,
"project_view_id": 1,
}, false)
db.AssertExists(t, "task_positions", map[string]interface{}{
"task_id": 51,
"project_view_id": 4,
}, false)
db.AssertExists(t, "task_buckets", map[string]interface{}{
"task_id": 51,
"project_view_id": 4,
"bucket_id": 1,
}, false)
s := db.NewSession()
defer s.Close()
read := &Task{ID: 51}
require.NoError(t, read.ReadOne(s, u1))
})
t.Run("done task restores into the done bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
setTaskColumnsForTest(t, 51, map[string]any{"done": true})
_, err := restoreTaskForTest(t, 51, u1)
require.NoError(t, err)
db.AssertExists(t, "task_buckets", map[string]interface{}{
"task_id": 51,
"project_view_id": 4,
"bucket_id": 3,
}, false)
})
t.Run("refreshes task state loaded during the permission check", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
defer events.CleanupPending(s)
task := &Task{ID: 51}
can, err := task.CanRestore(s, u1)
require.NoError(t, err)
require.True(t, can)
_, err = s.Unscoped().ID(task.ID).Cols("done").Update(&Task{Done: true})
require.NoError(t, err)
require.NoError(t, task.Restore(s, u1))
require.NoError(t, s.Commit())
assert.True(t, task.Done)
db.AssertExists(t, "task_buckets", map[string]interface{}{
"task_id": 51,
"project_view_id": 4,
"bucket_id": 3,
}, false)
})
t.Run("round trip through delete keeps the deleter cleared", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
trashTaskForTest(t, 1, u1)
_, err := restoreTaskForTest(t, 1, u1)
require.NoError(t, err)
db.AssertExists(t, "tasks", map[string]interface{}{
"id": 1,
"deleted_at": nil,
"deleted_by_id": nil,
}, false)
db.AssertExists(t, "task_buckets", map[string]interface{}{
"task_id": 1,
"project_view_id": 4,
}, false)
})
t.Run("write link share can restore in its project", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
share := &LinkSharing{
ID: 2,
ProjectID: 2,
Permission: PermissionWrite,
}
trashTaskForTest(t, 13, share)
_, err := restoreTaskForTest(t, 13, share)
require.NoError(t, err)
db.AssertExists(t, "tasks", map[string]interface{}{
"id": 13,
"deleted_at": nil,
}, false)
})
t.Run("read-only link share cannot restore", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
_, err := restoreTaskForTest(t, 51, &LinkSharing{
ID: 1,
ProjectID: 1,
Permission: PermissionRead,
})
require.ErrorIs(t, err, ErrGenericForbidden{})
})
t.Run("unrelated user cannot tell the task is trashed", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
_, err := restoreTaskForTest(t, 51, &user.User{ID: 2})
assert.True(t, IsErrTaskDoesNotExist(err))
db.AssertMissing(t, "tasks", map[string]interface{}{
"id": 51,
"deleted_at": nil,
})
db.AssertExists(t, "tasks", map[string]interface{}{"id": 51}, false)
})
t.Run("reader cannot restore", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
setTaskColumnsForTest(t, 32, map[string]any{"deleted_at": time.Now()})
_, err := restoreTaskForTest(t, 32, u1)
require.ErrorIs(t, err, ErrGenericForbidden{})
})
t.Run("refused in an archived project", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
setTaskColumnsForTest(t, 36, map[string]any{"deleted_at": time.Now()})
_, err := restoreTaskForTest(t, 36, u1)
require.Error(t, err)
assert.True(t, IsErrProjectIsArchived(err))
})
t.Run("second restore of the same loaded task is refused", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
defer events.CleanupPending(s)
first := &Task{ID: 51}
second := &Task{ID: 51}
_, err := first.CanRestore(s, u1)
require.NoError(t, err)
_, err = second.CanRestore(s, u1)
require.NoError(t, err)
require.NoError(t, first.Restore(s, u1))
err = second.Restore(s, u1)
assert.EqualError(t, err, "The task is not in the trash. [ID: 51]")
})
t.Run("another transaction restoring the task returns a conflict", func(t *testing.T) {
if db.Type() != schemas.MYSQL {
t.Skip("MySQL row-lock concurrency regression")
}
db.LoadAndAssertFixtures(t)
first := db.NewSession()
defer first.Close()
defer events.CleanupPending(first)
task := &Task{ID: 51}
can, err := task.CanRestore(first, u1)
require.NoError(t, err)
require.True(t, can)
require.NoError(t, task.Restore(first, u1))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
other := db.NewSession().Context(ctx)
done := make(chan struct{})
defer func() {
cancel()
_ = first.Rollback()
<-done
events.CleanupPending(other)
_ = other.Close()
}()
result := make(chan error, 1)
go func() {
defer close(done)
otherTask := &Task{ID: 51}
_, err := otherTask.CanRestore(other, u1)
if err == nil {
err = otherTask.Restore(other, u1)
}
result <- err
}()
select {
case err := <-result:
t.Fatalf("Concurrent restore did not wait for the row lock: %v", err)
case <-time.After(100 * time.Millisecond):
}
require.NoError(t, first.Commit())
select {
case err := <-result:
require.EqualError(t, err, "The task is not in the trash. [ID: 51]")
case <-time.After(10 * time.Second):
t.Fatal("Concurrent restore did not finish after commit")
}
})
t.Run("reappears in saved filter views", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
view, _ := createKanbanFilterView(t, s, 9999, 9999, 1, "done = false")
require.NoError(t, s.Commit())
_ = s.Close()
events.ClearDispatchedEvents()
_, err := restoreTaskForTest(t, 51, u1)
require.NoError(t, err)
dispatched := events.GetDispatchedEvents((&TaskRestoredEvent{}).Name())
require.Len(t, dispatched, 1)
inFilterView := map[string]interface{}{
"task_id": 51,
"project_view_id": view.ID,
}
db.AssertMissing(t, "task_buckets", inFilterView)
events.TestListener(t, dispatched[0], &UpdateTaskInSavedFilterViews{})
db.AssertExists(t, "task_buckets", inFilterView, false)
})
t.Run("live task is not in the trash", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
_, err := restoreTaskForTest(t, 1, u1)
assert.EqualError(t, err, "The task is not in the trash. [ID: 1]")
})
t.Run("a loaded task purged before restore stays not found", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
task := &Task{ID: 51}
can, err := task.CanRestore(s, u1)
require.NoError(t, err)
require.True(t, can)
_, err = s.Unscoped().ID(task.ID).Delete(&Task{})
require.NoError(t, err)
assert.True(t, IsErrTaskDoesNotExist(task.Restore(s, u1)))
})
t.Run("refused while its project is in the trash", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
setTaskColumnsForTest(t, 53, map[string]any{"deleted_at": time.Now()})
_, err := restoreTaskForTest(t, 53, u1)
var inTrash ErrProjectIsInTrash
require.ErrorAs(t, err, &inTrash)
assert.Equal(t, int64(45), inTrash.ID)
db.AssertMissing(t, "tasks", map[string]interface{}{
"id": 53,
"deleted_at": nil,
})
})
}
func TestTaskTrash_InstanceAdminInArchivedProject(t *testing.T) {
setup := func(t *testing.T) *user.User {
db.LoadAndAssertFixtures(t)
license.SetForTests([]license.Feature{license.FeatureAdminPanel})
t.Cleanup(license.ResetForTests)
s := db.NewAutocommitSession()
defer s.Close()
_, err := s.ID(int64(2)).Cols("is_admin").Update(&user.User{IsAdmin: true})
require.NoError(t, err)
setTaskColumnsForTest(t, 36, map[string]any{"deleted_at": time.Now()})
return &user.User{
ID: 2,
IsAdmin: true,
}
}
t.Run("restore is refused", func(t *testing.T) {
admin := setup(t)
_, err := restoreTaskForTest(t, 36, admin)
assert.True(t, IsErrProjectIsArchived(err), "got %v", err)
})
t.Run("permanent delete is refused", func(t *testing.T) {
admin := setup(t)
err := deletePermanentlyForTest(t, 36, admin)
assert.True(t, IsErrProjectIsArchived(err), "got %v", err)
db.AssertExists(t, "tasks", map[string]interface{}{"id": 36}, false)
})
}
func trashedTaskIDs(tasks []*TrashedTask) []int64 {
ids := make([]int64, 0, len(tasks))
for _, task := range tasks {
ids = append(ids, task.ID)
}
return ids
}
func TestGetTrashedTasks(t *testing.T) {
u1 := &user.User{ID: 1}
setup := func(t *testing.T) {
db.LoadAndAssertFixtures(t)
trashTaskForTest(t, 1, u1)
// Task 36 is in archived project 22, task 32 in project 3 where user 1 only reads
setTaskColumnsForTest(t, 36, map[string]any{"deleted_at": time.Now()})
setTaskColumnsForTest(t, 32, map[string]any{"deleted_at": time.Now()})
}
t.Run("writable live projects only, newest first", func(t *testing.T) {
setup(t)
s := db.NewSession()
defer s.Close()
tasks, total, err := GetTrashedTasks(s, u1, 1, 50)
require.NoError(t, err)
assert.Equal(t, []int64{
1,
51,
}, trashedTaskIDs(tasks))
assert.Equal(t, int64(2), total)
require.NotNil(t, tasks[0].DeletedBy)
assert.Equal(t, int64(1), tasks[0].DeletedBy.ID)
assert.False(t, tasks[0].DeletedAt.IsZero())
// Task 51 was deleted before deleted_by_id existed
assert.Nil(t, tasks[1].DeletedBy)
assert.NotEmpty(t, tasks[1].Identifier)
})
t.Run("paginated", func(t *testing.T) {
setup(t)
s := db.NewSession()
defer s.Close()
tasks, total, err := GetTrashedTasks(s, u1, 2, 1)
require.NoError(t, err)
assert.Equal(t, []int64{51}, trashedTaskIDs(tasks))
assert.Equal(t, int64(2), total)
})
t.Run("owner of another project sees its trash", func(t *testing.T) {
setup(t)
s := db.NewSession()
defer s.Close()
tasks, _, err := GetTrashedTasks(s, &user.User{ID: 3}, 1, 50)
require.NoError(t, err)
assert.Contains(t, trashedTaskIDs(tasks), int64(32))
assert.NotContains(t, trashedTaskIDs(tasks), int64(51))
})
t.Run("link shares have no trash", func(t *testing.T) {
setup(t)
s := db.NewSession()
defer s.Close()
share := &LinkSharing{
ID: 2,
ProjectID: 2,
Permission: PermissionWrite,
}
_, _, err := GetTrashedTasks(s, share, 1, 50)
require.ErrorIs(t, err, ErrGenericForbidden{})
})
}
func TestTask_DeletePermanently(t *testing.T) {
t.Run("owner purges a trashed task", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
events.ClearDispatchedEvents()
require.NoError(t, deletePermanentlyForTest(t, 51, &user.User{ID: 1}))
db.AssertMissing(t, "tasks", map[string]interface{}{"id": 51})
doers := purgedTaskDoers(t)
require.Len(t, doers, 1)
require.NotNil(t, doers[51])
assert.Equal(t, int64(1), doers[51].ID)
})
t.Run("admin shares purge", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
u1 := &user.User{ID: 1}
// User 1 is admin of project 11 by user share and of project 8 by team share
trashTaskForTest(t, 20, u1)
trashTaskForTest(t, 17, u1)
require.NoError(t, deletePermanentlyForTest(t, 20, u1))
require.NoError(t, deletePermanentlyForTest(t, 17, u1))
db.AssertMissing(t, "tasks", map[string]interface{}{"id": 20})
db.AssertMissing(t, "tasks", map[string]interface{}{"id": 17})
})
t.Run("writer can restore but not purge", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
u1 := &user.User{ID: 1}
// User 1 can write to project 10 by user share and to project 7 by team share
for _, taskID := range []int64{
19,
16,
} {
trashTaskForTest(t, taskID, u1)
require.ErrorIs(t, deletePermanentlyForTest(t, taskID, u1), ErrGenericForbidden{})
db.AssertExists(t, "tasks", map[string]interface{}{"id": taskID}, false)
_, err := restoreTaskForTest(t, taskID, u1)
require.NoError(t, err)
}
})
t.Run("unrelated user cannot tell the task is trashed", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
assert.True(t, IsErrTaskDoesNotExist(deletePermanentlyForTest(t, 51, &user.User{ID: 2})))
db.AssertExists(t, "tasks", map[string]interface{}{"id": 51}, false)
})
t.Run("reader is denied", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
setTaskColumnsForTest(t, 32, map[string]any{"deleted_at": time.Now()})
require.ErrorIs(t, deletePermanentlyForTest(t, 32, &user.User{ID: 1}), ErrGenericForbidden{})
db.AssertExists(t, "tasks", map[string]interface{}{"id": 32}, false)
})
t.Run("refused in an archived project", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
setTaskColumnsForTest(t, 36, map[string]any{"deleted_at": time.Now()})
assert.True(t, IsErrProjectIsArchived(deletePermanentlyForTest(t, 36, &user.User{ID: 1})))
db.AssertExists(t, "tasks", map[string]interface{}{"id": 36}, false)
})
t.Run("write link share is denied", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
share := &LinkSharing{
ID: 2,
ProjectID: 2,
Permission: PermissionWrite,
}
trashTaskForTest(t, 13, share)
require.ErrorIs(t, deletePermanentlyForTest(t, 13, share), ErrGenericForbidden{})
})
t.Run("live task is not in the trash", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
err := deletePermanentlyForTest(t, 1, &user.User{ID: 1})
assert.True(t, IsErrTaskDoesNotExist(err))
db.AssertExists(t, "tasks", map[string]interface{}{
"id": 1,
"deleted_at": nil,
}, false)
})
}
func TestEmptyTaskTrash(t *testing.T) {
db.LoadAndAssertFixtures(t)
u1 := &user.User{ID: 1}
// Deleted by someone else, still purged
trashTaskForTest(t, 1, u1)
setTaskColumnsForTest(t, 1, map[string]any{"deleted_by_id": 2})
setTaskColumnsForTest(t, 36, map[string]any{"deleted_at": time.Now()})
setTaskColumnsForTest(t, 32, map[string]any{"deleted_at": time.Now()})
trashTaskForTest(t, 19, u1)
trashTaskForTest(t, 20, u1)
events.ClearDispatchedEvents()
s := db.NewSession()
defer s.Close()
require.NoError(t, EmptyTaskTrash(s, u1, true))
require.NoError(t, s.Commit())
events.DispatchPending(context.Background(), s)
db.AssertMissing(t, "tasks", map[string]interface{}{"id": 1})
db.AssertMissing(t, "tasks", map[string]interface{}{"id": 51})
db.AssertMissing(t, "tasks", map[string]interface{}{"id": 20})
// Archived, read-only and write-only projects are out of scope
db.AssertExists(t, "tasks", map[string]interface{}{"id": 36}, false)
db.AssertExists(t, "tasks", map[string]interface{}{"id": 32}, false)
db.AssertExists(t, "tasks", map[string]interface{}{"id": 19}, false)
doers := purgedTaskDoers(t)
assert.ElementsMatch(t, []int64{
1,
20,
51,
}, slices.Collect(maps.Keys(doers)))
for id, doer := range doers {
require.NotNil(t, doer, "task %d", id)
assert.Equal(t, int64(1), doer.ID, "task %d", id)
}
}
func TestTask_CanRead_Trashed(t *testing.T) {
t.Run("reader learns the task is in the trash", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
trashTaskForTest(t, 1, &user.User{ID: 1})
s := db.NewSession()
defer s.Close()
_, _, err := (&Task{ID: 1}).CanRead(s, &user.User{ID: 1})
var inTrash ErrTaskIsInTrash
require.ErrorAs(t, err, &inTrash)
assert.Equal(t, int64(1), inTrash.ID)
assert.Equal(t, int64(1), inTrash.ProjectID)
assert.False(t, inTrash.DeletedAt.IsZero())
require.NotNil(t, inTrash.DeletedBy)
assert.Equal(t, int64(1), inTrash.DeletedBy.ID)
assert.True(t, inTrash.CanRestore)
})
t.Run("read-only reader can't restore", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
// User 1 only reads project 3
setTaskColumnsForTest(t, 32, map[string]any{"deleted_at": time.Now()})
s := db.NewSession()
defer s.Close()
_, _, err := (&Task{ID: 32}).CanRead(s, &user.User{ID: 1})
var inTrash ErrTaskIsInTrash
require.ErrorAs(t, err, &inTrash)
assert.False(t, inTrash.CanRestore)
})
t.Run("archived project can't restore", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
// Task 36 is in archived project 22, owned by user 1
setTaskColumnsForTest(t, 36, map[string]any{"deleted_at": time.Now()})
s := db.NewSession()
defer s.Close()
_, _, err := (&Task{ID: 36}).CanRead(s, &user.User{ID: 1})
var inTrash ErrTaskIsInTrash
require.ErrorAs(t, err, &inTrash)
assert.False(t, inTrash.CanRestore)
})
t.Run("unknown deleter", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
_, _, err := (&Task{ID: 51}).CanRead(s, &user.User{ID: 1})
var inTrash ErrTaskIsInTrash
require.ErrorAs(t, err, &inTrash)
assert.Nil(t, inTrash.DeletedBy)
})
t.Run("no access hides that the task exists", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
_, _, err := (&Task{ID: 51}).CanRead(s, &user.User{ID: 2})
assert.True(t, IsErrTaskDoesNotExist(err))
})
}
func TestTask_WritesOnTrashedTask(t *testing.T) {
writes := []struct {
name string
check func(s *xorm.Session, a web.Auth, taskID int64) (bool, error)
}{
{
name: "update",
check: func(s *xorm.Session, a web.Auth, taskID int64) (bool, error) {
return (&Task{ID: taskID}).CanUpdate(s, a)
},
},
{
name: "delete",
check: func(s *xorm.Session, a web.Auth, taskID int64) (bool, error) {
return (&Task{ID: taskID}).CanDelete(s, a)
},
},
{
name: "comment",
check: func(s *xorm.Session, a web.Auth, taskID int64) (bool, error) {
return (&TaskComment{TaskID: taskID}).CanCreate(s, a)
},
},
{
name: "attachment",
check: func(s *xorm.Session, a web.Auth, taskID int64) (bool, error) {
return (&TaskAttachment{TaskID: taskID}).CanCreate(s, a)
},
},
{
name: "assignee",
check: func(s *xorm.Session, a web.Auth, taskID int64) (bool, error) {
return (&TaskAssginee{
TaskID: taskID,
UserID: 1,
}).CanCreate(s, a)
},
},
{
name: "labels",
check: func(s *xorm.Session, a web.Auth, taskID int64) (bool, error) {
return (&LabelTaskBulk{TaskID: taskID}).CanCreate(s, a)
},
},
{
name: "relation",
check: func(s *xorm.Session, a web.Auth, taskID int64) (bool, error) {
return (&TaskRelation{
TaskID: taskID,
OtherTaskID: 2,
RelationKind: RelationKindRelated,
}).CanCreate(s, a)
},
},
}
for _, w := range writes {
t.Run(w.name, func(t *testing.T) {
db.LoadAndAssertFixtures(t)
// User 1 only reads project 3
setTaskColumnsForTest(t, 32, map[string]any{"deleted_at": time.Now()})
s := db.NewSession()
defer s.Close()
_, err := w.check(s, &user.User{ID: 1}, 51)
require.ErrorAs(t, err, &ErrTaskIsInTrash{}, "writer")
_, err = w.check(s, &user.User{ID: 1}, 32)
require.ErrorAs(t, err, &ErrTaskIsInTrash{}, "reader")
_, err = w.check(s, &user.User{ID: 2}, 51)
assert.True(t, IsErrTaskDoesNotExist(err), "stranger: %v", err)
})
}
}
func TestEmptyTaskTrash_OwnDeletionsOnly(t *testing.T) {
db.LoadAndAssertFixtures(t)
u1 := &user.User{ID: 1}
trashTaskForTest(t, 1, u1)
setTaskColumnsForTest(t, 1, map[string]any{"deleted_by_id": 2})
trashTaskForTest(t, 20, u1)
s := db.NewSession()
defer s.Close()
require.NoError(t, EmptyTaskTrash(s, u1, false))
require.NoError(t, s.Commit())
db.AssertMissing(t, "tasks", map[string]interface{}{"id": 20})
// Deleted by user 2, and task 51 by nobody known
db.AssertExists(t, "tasks", map[string]interface{}{"id": 1}, false)
db.AssertExists(t, "tasks", map[string]interface{}{"id": 51}, false)
}
func TestCountTaskTrash(t *testing.T) {
db.LoadAndAssertFixtures(t)
u1 := &user.User{ID: 1}
trashTaskForTest(t, 1, u1)
setTaskColumnsForTest(t, 1, map[string]any{"deleted_by_id": 2})
trashTaskForTest(t, 20, u1)
// Write-only project 10 is out of the purge scope
trashTaskForTest(t, 19, u1)
s := db.NewSession()
defer s.Close()
count, err := CountTaskTrash(s, u1)
require.NoError(t, err)
// 1 (user 2), 20 (user 1), 51 (unknown deleter)
assert.Equal(t, &TrashCount{Total: 3, ByOthers: 2}, count)
_, err = CountTaskTrash(s, &LinkSharing{ID: 2, ProjectID: 2, Permission: PermissionWrite})
require.ErrorIs(t, err, ErrGenericForbidden{})
}