Files
vikunja/pkg/models/project_trash_test.go
kolaenteandTink 6df6a9ae46 style(projects): simplify and format project trash code
Address pr-swarm finding: restating and multi-line comments
2026-11-04 23:14:28 +01:00

1201 lines
36 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"
"encoding/json"
"io/fs"
"net/http"
"testing"
"time"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/web"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/builder"
)
// Fixture projects 45 and 46 (child of 45) are in user 1's trash.
func trashProjectForTest(t *testing.T, projectID int64, a web.Auth) {
s := db.NewSession()
defer s.Close()
require.NoError(t, (&Project{ID: projectID}).Delete(s, a))
require.NoError(t, s.Commit())
}
func TestTrashProject_SubProjects(t *testing.T) {
u6 := &user.User{ID: 6}
t.Run("refused while a sub-project is another user's default", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// 27 -> 12 -> 25 -> 26
_, err := s.Table("projects").Where("id = ?", 26).Update(map[string]any{"owner_id": 2})
require.NoError(t, err)
_, err = s.Table("users").Where("id = ?", 2).Update(map[string]any{"default_project_id": 26})
require.NoError(t, err)
err = (&Project{ID: 12}).Delete(s, u6)
var cannotDelete *ErrCannotDeleteDefaultProject
require.ErrorAs(t, err, &cannotDelete)
assert.Equal(t, int64(26), cannotDelete.ProjectID)
})
t.Run("stamps the whole subtree alike", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
// 27 -> 12 -> 25 -> 26
trashProjectForTest(t, 12, u6)
s := db.NewSession()
defer s.Close()
projects := []*Project{}
require.NoError(t, s.Unscoped().In("id", 12, 25, 26).Find(&projects))
require.Len(t, projects, 3)
require.False(t, projects[0].DeletedAt.IsZero())
for _, p := range projects {
assert.Equal(t, projects[0].DeletedAt, p.DeletedAt, "project %d", p.ID)
assert.Equal(t, int64(6), p.DeletedByID, "project %d", p.ID)
}
db.AssertExists(t, "projects", map[string]interface{}{
"id": 27,
"deleted_at": nil,
}, false)
})
t.Run("one deleted event per trashed project", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
events.ClearDispatchedEvents()
s := db.NewSession()
defer s.Close()
require.NoError(t, (&Project{ID: 12}).Delete(s, u6))
require.NoError(t, s.Commit())
events.DispatchPending(context.Background(), s)
trashed := []int64{}
for _, e := range events.GetDispatchedEvents((&ProjectDeletedEvent{}).Name()) {
trashed = append(trashed, e.(*ProjectDeletedEvent).Project.ID)
}
assert.ElementsMatch(t, []int64{
12,
25,
26,
}, trashed)
})
}
func TestTrashProject_DefaultProjects(t *testing.T) {
// Project 4 belongs to user 3 and is the default of users 2 and 3.
u3 := &user.User{ID: 3}
t.Run("owner may trash their own default", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
trashProjectForTest(t, 4, u3)
db.AssertCount(t, "projects", builder.And(
builder.Eq{"id": 4},
builder.NotNull{"deleted_at"},
), 1)
})
t.Run("clears a shared default for good", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
shareProjectForTest(t, 4, 2, PermissionRead)
trashProjectForTest(t, 4, u3)
db.AssertExists(t, "users", map[string]interface{}{
"id": 2,
"default_project_id": 0,
}, false)
require.NoError(t, restoreProjectForTest(t, 4, u3))
db.AssertExists(t, "users", map[string]interface{}{
"id": 2,
"default_project_id": 0,
}, false)
})
}
func restoreProjectForTest(t *testing.T, projectID int64, a web.Auth) error {
s := db.NewSession()
defer s.Close()
project := &Project{ID: projectID}
can, err := project.CanRestore(s, a)
if err != nil {
return err
}
if !can {
return ErrGenericForbidden{}
}
if err := project.Restore(s, a); err != nil {
return err
}
require.NoError(t, s.Commit())
events.DispatchPending(context.Background(), s)
return nil
}
func TestProject_Restore(t *testing.T) {
u1 := &user.User{ID: 1}
t.Run("restores the project with its sub-projects", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
require.NoError(t, restoreProjectForTest(t, 45, u1))
events.AssertDispatched(t, &ProjectRestoredEvent{})
db.AssertExists(t, "projects", map[string]interface{}{
"id": 45,
"deleted_at": nil,
"deleted_by_id": nil,
}, false)
db.AssertExists(t, "projects", map[string]interface{}{
"id": 46,
"deleted_at": nil,
}, false)
s := db.NewSession()
defer s.Close()
task := &Task{}
has, err := s.ID(53).Get(task)
require.NoError(t, err)
require.True(t, has)
// CalDAV clients only pick it up again when updated moves
assert.True(t, task.Updated.After(time.Date(2018, 12, 1, 1, 12, 4, 0, time.UTC)), "updated is %s", task.Updated)
can, _, err := (&Task{ID: 54}).CanRead(s, u1)
require.NoError(t, err)
assert.True(t, can)
})
t.Run("one restored event per project", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
events.ClearDispatchedEvents()
require.NoError(t, restoreProjectForTest(t, 45, u1))
restored := []int64{}
for _, e := range events.GetDispatchedEvents((&ProjectRestoredEvent{}).Name()) {
restored = append(restored, e.(*ProjectRestoredEvent).Project.ID)
}
assert.ElementsMatch(t, []int64{
45,
46,
}, restored)
})
t.Run("one deleted event per project when trashed again", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
require.NoError(t, restoreProjectForTest(t, 45, u1))
events.ClearDispatchedEvents()
s := db.NewSession()
defer s.Close()
require.NoError(t, (&Project{ID: 45}).Delete(s, u1))
require.NoError(t, s.Commit())
events.DispatchPending(context.Background(), s)
trashed := []int64{}
for _, e := range events.GetDispatchedEvents((&ProjectDeletedEvent{}).Name()) {
trashed = append(trashed, e.(*ProjectDeletedEvent).Project.ID)
}
assert.ElementsMatch(t, []int64{
45,
46,
}, trashed)
})
t.Run("brings back sub-projects trashed separately before", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
u6 := &user.User{ID: 6}
// 27 -> 12 -> 25 -> 26
trashProjectForTest(t, 26, u6)
trashProjectForTest(t, 12, u6)
require.NoError(t, restoreProjectForTest(t, 12, u6))
for _, id := range []int64{
12,
25,
26,
} {
db.AssertExists(t, "projects", map[string]interface{}{
"id": id,
"deleted_at": nil,
}, false)
}
})
t.Run("brings back a sub-project someone else trashed before", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
u6 := &user.User{ID: 6}
s := db.NewSession()
_, err := s.Insert(&ProjectUser{
UserID: 1,
ProjectID: 25,
Permission: PermissionAdmin,
})
require.NoError(t, err)
require.NoError(t, s.Commit())
s.Close()
// 27 -> 12 -> 25 -> 26
trashProjectForTest(t, 26, u1)
trashProjectForTest(t, 12, u6)
require.NoError(t, restoreProjectForTest(t, 12, u6))
db.AssertExists(t, "projects", map[string]interface{}{
"id": 26,
"deleted_at": nil,
"deleted_by_id": nil,
}, false)
})
t.Run("leaves separately trashed tasks in the trash", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
trashProjectForTest(t, 1, u1)
require.NoError(t, restoreProjectForTest(t, 1, u1))
db.AssertCount(t, "tasks", builder.And(
builder.Eq{"id": 51},
builder.NotNull{"deleted_at"},
), 1)
})
t.Run("refused while the parent is in the trash", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
err := restoreProjectForTest(t, 46, u1)
require.ErrorAs(t, err, &ErrParentProjectIsInTrash{})
})
t.Run("refused while the parent is archived", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
// 21 is a child of archived 22
s := db.NewSession()
_, err := s.Table("projects").Where("id = ?", 21).Update(map[string]any{"deleted_at": time.Now()})
require.NoError(t, err)
require.NoError(t, s.Commit())
s.Close()
err = restoreProjectForTest(t, 21, u1)
assert.True(t, IsErrParentProjectIsArchived(err))
})
t.Run("admin share may restore, read share may not", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
// User 1 is admin of project 11 and reads project 9, both owned by others
trashProjectForTest(t, 11, &user.User{ID: 6})
trashProjectForTest(t, 9, &user.User{ID: 6})
require.NoError(t, restoreProjectForTest(t, 11, u1))
require.ErrorIs(t, restoreProjectForTest(t, 9, u1), ErrGenericForbidden{})
})
t.Run("unrelated user does not see it", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
assert.True(t, IsErrProjectDoesNotExist(restoreProjectForTest(t, 45, &user.User{ID: 2})))
})
t.Run("live project is not in the trash", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
assert.True(t, IsErrProjectDoesNotExist(restoreProjectForTest(t, 1, u1)))
})
}
func TestProject_ReadTrashed(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
_, _, err := (&Project{ID: 45}).CanRead(s, &user.User{ID: 1})
var inTrash ErrProjectIsInTrash
require.ErrorAs(t, err, &inTrash)
require.NotNil(t, inTrash.DeletedBy)
assert.Equal(t, int64(1), inTrash.DeletedBy.ID)
assert.True(t, inTrash.CanRestore)
assert.Zero(t, inTrash.TrashedRootID)
_, _, err = (&Project{ID: 45}).CanRead(s, &user.User{ID: 2})
assert.True(t, IsErrProjectDoesNotExist(err))
_, _, err = (&Task{ID: 53}).CanRead(s, &user.User{ID: 1})
require.ErrorAs(t, err, &inTrash)
}
func shareProjectForTest(t *testing.T, projectID, userID int64, permission Permission) {
t.Helper()
s := db.NewSession()
defer s.Close()
_, err := s.Insert(&ProjectUser{
UserID: userID,
ProjectID: projectID,
Permission: permission,
})
require.NoError(t, err)
require.NoError(t, s.Commit())
}
func TestProject_ReadTrashed_CanRestore(t *testing.T) {
u1 := &user.User{ID: 1}
readInTrash := func(t *testing.T, projectID int64, a web.Auth) ErrProjectIsInTrash {
s := db.NewSession()
defer s.Close()
_, _, err := (&Project{ID: projectID}).CanRead(s, a)
var inTrash ErrProjectIsInTrash
require.ErrorAs(t, err, &inTrash)
return inTrash
}
t.Run("not while the parent is in the trash", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
inTrash := readInTrash(t, 46, u1)
assert.False(t, inTrash.CanRestore)
assert.Equal(t, int64(45), inTrash.TrashedRootID)
assert.True(t, inTrash.CanRestoreRoot)
})
t.Run("a reader of the root can't restore it", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
shareProjectForTest(t, 45, 2, PermissionRead)
inTrash := readInTrash(t, 46, &user.User{ID: 2})
assert.Equal(t, int64(45), inTrash.TrashedRootID)
assert.False(t, inTrash.CanRestoreRoot)
})
t.Run("the root can't be restored while its parent is archived", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
_, err := s.Table("projects").Where("id = ?", 45).Update(map[string]any{"parent_project_id": 22})
require.NoError(t, err)
require.NoError(t, s.Commit())
s.Close()
inTrash := readInTrash(t, 46, u1)
assert.Equal(t, int64(45), inTrash.TrashedRootID)
assert.False(t, inTrash.CanRestoreRoot)
})
t.Run("an unreachable root is omitted", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
shareProjectForTest(t, 46, 2, PermissionAdmin)
inTrash := readInTrash(t, 46, &user.User{ID: 2})
assert.Zero(t, inTrash.TrashedRootID)
assert.False(t, inTrash.CanRestoreRoot)
details, err := json.Marshal(inTrash)
require.NoError(t, err)
assert.NotContains(t, string(details), "trashed_root_id")
})
t.Run("points deeper sub-projects to the topmost trashed ancestor", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
u6 := &user.User{ID: 6}
// 12 -> 25 -> 26
trashProjectForTest(t, 26, u6)
trashProjectForTest(t, 12, u6)
inTrash := readInTrash(t, 26, u6)
assert.False(t, inTrash.CanRestore)
assert.Equal(t, int64(12), inTrash.TrashedRootID)
})
t.Run("not while the parent is archived", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
// 21 is a child of archived 22
s := db.NewSession()
_, err := s.Table("projects").Where("id = ?", 21).Update(map[string]any{"deleted_at": time.Now()})
require.NoError(t, err)
require.NoError(t, s.Commit())
s.Close()
inTrash := readInTrash(t, 21, u1)
assert.False(t, inTrash.CanRestore)
assert.Zero(t, inTrash.TrashedRootID)
})
t.Run("not while the parent row is gone", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
_, err := s.Table("projects").Where("id = ?", 46).Update(map[string]any{"parent_project_id": 99999})
require.NoError(t, err)
require.NoError(t, s.Commit())
s.Close()
assert.False(t, readInTrash(t, 46, u1).CanRestore)
err = restoreProjectForTest(t, 46, u1)
var missing ErrParentProjectDoesNotExist
require.ErrorAs(t, err, &missing)
assert.Equal(t, http.StatusPreconditionFailed, missing.HTTPError().HTTPCode)
assert.Equal(t, ErrCodeParentProjectDoesNotExist, missing.HTTPError().Code)
})
}
func TestProject_ReadTrashed_Shares(t *testing.T) {
t.Run("read share sees the trash but can't restore", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
// User 1 reads project 9 of user 6
trashProjectForTest(t, 9, &user.User{ID: 6})
s := db.NewSession()
defer s.Close()
_, _, err := (&Project{ID: 9}).CanRead(s, &user.User{ID: 1})
var inTrash ErrProjectIsInTrash
require.ErrorAs(t, err, &inTrash)
assert.False(t, inTrash.CanRestore)
})
t.Run("link share of the project gets a plain not found", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
share := &LinkSharing{
ID: 3,
ProjectID: 45,
Permission: PermissionAdmin,
}
_, _, err := (&Project{ID: 45}).CanRead(s, share)
assert.True(t, IsErrProjectDoesNotExist(err))
assert.NotErrorAs(t, err, &ErrProjectIsInTrash{})
})
}
func TestProject_ReadTrashed_InstanceAdmin(t *testing.T) {
db.LoadAndAssertFixtures(t)
admin := setUpInstanceAdmin(t)
s := db.NewSession()
defer s.Close()
_, _, err := (&Project{ID: 45}).CanRead(s, admin)
var inTrash ErrProjectIsInTrash
require.ErrorAs(t, err, &inTrash)
assert.True(t, inTrash.CanRestore)
}
func TestProject_TrashHidesContents(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
u1 := &user.User{ID: 1}
projects, _, _, err := (&Project{}).ReadAll(s, u1, "", 1, 500)
require.NoError(t, err)
projectIDs := []int64{}
for _, p := range projects.([]*Project) {
projectIDs = append(projectIDs, p.ID)
}
assert.Contains(t, projectIDs, int64(1))
assert.NotContains(t, projectIDs, int64(45))
assert.NotContains(t, projectIDs, int64(46))
tasks, _, _, err := (&TaskCollection{}).ReadAll(s, u1, "", 1, 500)
require.NoError(t, err)
taskIDs := []int64{}
for _, task := range tasks.([]*Task) {
taskIDs = append(taskIDs, task.ID)
}
assert.Contains(t, taskIDs, int64(1))
assert.NotContains(t, taskIDs, int64(53))
assert.NotContains(t, taskIDs, int64(54))
}
func TestGetTrashedProjects(t *testing.T) {
t.Run("topmost trashed projects with their sub-project count", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
projects, total, err := GetTrashedProjects(s, &user.User{ID: 1}, 1, 50)
require.NoError(t, err)
require.Len(t, projects, 1)
assert.Equal(t, int64(1), total)
assert.Equal(t, int64(45), projects[0].ID)
require.Len(t, projects[0].TrashedSubProjects, 1)
assert.Equal(t, &TrashedSubProject{
ID: 46,
Title: "Test46 trashed with its parent",
ParentProjectID: 45,
Depth: 1,
}, projects[0].TrashedSubProjects[0])
require.NotNil(t, projects[0].DeletedBy)
assert.Equal(t, int64(1), projects[0].DeletedBy.ID)
})
t.Run("separately trashed sub-projects fold into their ancestor", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
u6 := &user.User{ID: 6}
trashProjectForTest(t, 26, u6)
trashProjectForTest(t, 12, u6)
s := db.NewSession()
defer s.Close()
projects, _, err := GetTrashedProjects(s, u6, 1, 50)
require.NoError(t, err)
require.Len(t, projects, 1)
assert.Equal(t, int64(12), projects[0].ID)
// 12 -> 25 -> 26, shallowest first
require.Len(t, projects[0].TrashedSubProjects, 2)
assert.Equal(t, int64(25), projects[0].TrashedSubProjects[0].ID)
assert.Equal(t, int64(26), projects[0].TrashedSubProjects[1].ID)
assert.Equal(t, int64(25), projects[0].TrashedSubProjects[1].ParentProjectID)
})
t.Run("other users see nothing", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
projects, _, err := GetTrashedProjects(s, &user.User{ID: 2}, 1, 50)
require.NoError(t, err)
assert.Empty(t, projects)
})
t.Run("link shares have no trash", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
_, _, err := GetTrashedProjects(s, &LinkSharing{
ID: 3,
ProjectID: 3,
Permission: PermissionAdmin,
}, 1, 50)
require.ErrorIs(t, err, ErrGenericForbidden{})
})
}
func purgedProjectDoers(t *testing.T) map[int64]*user.User {
t.Helper()
doers := map[int64]*user.User{}
for _, e := range events.GetDispatchedEvents((&ProjectPurgedEvent{}).Name()) {
purged, ok := e.(*ProjectPurgedEvent)
require.True(t, ok)
doers[purged.Project.ID] = purged.Doer
}
return doers
}
func TestWebhookListener_ParentReceivesChildPurged(t *testing.T) {
db.LoadAndAssertFixtures(t)
u6 := &user.User{ID: 6}
// 12 -> 25 -> 26, all user6's; user15 only reads 26.
trashProjectForTest(t, 26, u6)
s := db.NewSession()
defer s.Close()
hook := func(createdByID int64) *Webhook {
w := &Webhook{
TargetURL: "https://example.com/purged",
Events: []string{"project.purged"},
ProjectID: 25,
CreatedByID: createdByID,
}
_, err := s.Insert(w)
require.NoError(t, err)
return w
}
owners := hook(6)
strangers := hook(15)
require.NoError(t, s.Commit())
events.ClearDispatchedEvents()
require.NoError(t, (&Project{ID: 26}).DeletePermanently(s, u6))
require.NoError(t, s.Commit())
events.DispatchPending(context.Background(), s)
purged := events.GetDispatchedEvents((&ProjectPurgedEvent{}).Name())
require.Len(t, purged, 1)
events.ClearDispatchedEvents()
events.TestListener(t, purged[0], &WebhookListener{EventName: "project.purged"})
delivered := webhookDeliveriesByID(t)
require.Contains(t, delivered, owners.ID)
assert.NotContains(t, delivered, strangers.ID, "creator can't read the webhook's project")
assert.NotContains(t, delivered[owners.ID].Payload.Data, "ancestor_ids")
}
func TestProject_DeletePermanently_SnapshotsAncestors(t *testing.T) {
db.LoadAndAssertFixtures(t)
u6 := &user.User{ID: 6}
// 27 -> 12 -> 25 -> 26
trashProjectForTest(t, 12, u6)
s := db.NewSession()
defer s.Close()
events.ClearDispatchedEvents()
require.NoError(t, (&Project{ID: 12}).DeletePermanently(s, u6))
require.NoError(t, s.Commit())
events.DispatchPending(context.Background(), s)
ancestors := map[int64][]int64{}
for _, e := range events.GetDispatchedEvents((&ProjectPurgedEvent{}).Name()) {
purged := e.(*ProjectPurgedEvent)
ancestors[purged.Project.ID] = purged.AncestorIDs
}
assert.ElementsMatch(t, []int64{27}, ancestors[12])
assert.ElementsMatch(t, []int64{
27,
12,
}, ancestors[25])
assert.ElementsMatch(t, []int64{
27,
12,
25,
}, ancestors[26])
}
func TestProject_DeletePermanently(t *testing.T) {
purge := func(t *testing.T, projectID int64, a web.Auth) error {
s := db.NewSession()
defer s.Close()
project := &Project{ID: projectID}
can, err := project.CanDeletePermanently(s, a)
if err != nil {
return err
}
if !can {
return ErrGenericForbidden{}
}
require.NoError(t, project.DeletePermanently(s, a))
if err := s.Commit(); err != nil {
return err
}
events.DispatchPending(context.Background(), s)
return nil
}
t.Run("admin purges a trashed project", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
events.ClearDispatchedEvents()
require.NoError(t, purge(t, 45, &user.User{ID: 1}))
db.AssertMissing(t, "projects", map[string]interface{}{"id": 45})
db.AssertMissing(t, "projects", map[string]interface{}{"id": 46})
doers := purgedProjectDoers(t)
require.Len(t, doers, 2)
for _, id := range []int64{
45,
46,
} {
require.NotNil(t, doers[id], "project %d", id)
assert.Equal(t, int64(1), doers[id].ID, "project %d", id)
}
})
t.Run("unrelated user does not see it", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
assert.True(t, IsErrProjectDoesNotExist(purge(t, 45, &user.User{ID: 2})))
db.AssertExists(t, "projects", map[string]interface{}{"id": 45}, false)
})
t.Run("live project is not in the trash", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
assert.True(t, IsErrProjectDoesNotExist(purge(t, 1, &user.User{ID: 1})))
})
t.Run("read share is denied", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
// User 1 reads project 9 of user 6
trashProjectForTest(t, 9, &user.User{ID: 6})
require.ErrorIs(t, purge(t, 9, &user.User{ID: 1}), ErrGenericForbidden{})
db.AssertExists(t, "projects", map[string]interface{}{"id": 9}, false)
})
t.Run("link share does not see it", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
share := &LinkSharing{
ID: 3,
ProjectID: 45,
Permission: PermissionAdmin,
}
assert.True(t, IsErrProjectDoesNotExist(purge(t, 45, share)))
db.AssertExists(t, "projects", map[string]interface{}{"id": 45}, false)
})
t.Run("takes the background along", func(t *testing.T) {
u6 := &user.User{ID: 6}
db.LoadAndAssertFixtures(t)
files.InitTestFileFixtures(t)
trashProjectForTest(t, 35, u6)
require.NoError(t, purge(t, 35, u6))
_, err := files.FileStat(&files.File{ID: 1})
require.ErrorIs(t, err, fs.ErrNotExist)
db.AssertMissing(t, "files", map[string]interface{}{"id": 1})
})
}
func TestEmptyProjectTrash(t *testing.T) {
t.Run("purges the auth's trash", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
events.ClearDispatchedEvents()
s := db.NewSession()
defer s.Close()
purged, err := EmptyProjectTrash(s, &user.User{ID: 1}, true)
require.NoError(t, err)
require.NoError(t, s.Commit())
events.DispatchPending(context.Background(), s)
assert.Equal(t, 1, purged)
db.AssertMissing(t, "projects", map[string]interface{}{"id": 45})
db.AssertMissing(t, "projects", map[string]interface{}{"id": 46})
doers := purgedProjectDoers(t)
require.Len(t, doers, 2)
for _, id := range []int64{
45,
46,
} {
require.NotNil(t, doers[id], "project %d", id)
assert.Equal(t, int64(1), doers[id].ID, "project %d", id)
}
})
t.Run("leaves other users' trash alone", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
purged, err := EmptyProjectTrash(s, &user.User{ID: 2}, true)
require.NoError(t, err)
require.NoError(t, s.Commit())
assert.Equal(t, 0, purged)
db.AssertExists(t, "projects", map[string]interface{}{"id": 45}, false)
db.AssertExists(t, "projects", map[string]interface{}{"id": 46}, false)
})
}
// User 1 only reads 12 through 27, so its admin share on 25 doesn't put 25 in their trash.
func TestProjectTrash_AdminOnlyOnTrashedChild(t *testing.T) {
u1 := &user.User{ID: 1}
setup := func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
_, err := s.Insert(&ProjectUser{
UserID: 1,
ProjectID: 25,
Permission: PermissionAdmin,
})
require.NoError(t, err)
require.NoError(t, s.Commit())
s.Close()
// 27 -> 12 -> 25 -> 26
trashProjectForTest(t, 12, &user.User{ID: 6})
}
t.Run("hidden from the trash list", func(t *testing.T) {
setup(t)
s := db.NewSession()
defer s.Close()
projects, total, err := GetTrashedProjects(s, u1, 1, 50)
require.NoError(t, err)
// Only the fixture trash
require.Len(t, projects, 1)
assert.Equal(t, int64(45), projects[0].ID)
assert.Equal(t, int64(1), total)
})
t.Run("reading tells it is in the trash, not restorable", func(t *testing.T) {
setup(t)
s := db.NewSession()
defer s.Close()
_, _, err := (&Project{ID: 25}).CanRead(s, u1)
var inTrash ErrProjectIsInTrash
require.ErrorAs(t, err, &inTrash)
assert.False(t, inTrash.CanRestore)
})
t.Run("restore is refused for the trashed parent", func(t *testing.T) {
setup(t)
require.ErrorAs(t, restoreProjectForTest(t, 25, u1), &ErrParentProjectIsInTrash{})
})
t.Run("emptying the trash leaves it", func(t *testing.T) {
setup(t)
s := db.NewSession()
defer s.Close()
purged, err := EmptyProjectTrash(s, u1, true)
require.NoError(t, err)
require.NoError(t, s.Commit())
assert.Equal(t, 1, purged)
db.AssertMissing(t, "projects", map[string]interface{}{"id": 45})
db.AssertExists(t, "projects", map[string]interface{}{"id": 25}, false)
})
t.Run("may still be purged directly", func(t *testing.T) {
setup(t)
s := db.NewSession()
defer s.Close()
project := &Project{ID: 25}
can, err := project.CanDeletePermanently(s, u1)
require.NoError(t, err)
require.True(t, can)
require.NoError(t, project.DeletePermanently(s, u1))
require.NoError(t, s.Commit())
db.AssertMissing(t, "projects", map[string]interface{}{"id": 25})
db.AssertMissing(t, "projects", map[string]interface{}{"id": 26})
db.AssertExists(t, "projects", map[string]interface{}{"id": 12}, false)
})
}
func TestDeleteExpiredProjects(t *testing.T) {
trashedAt := time.Date(2018, 12, 3, 10, 0, 0, 0, time.UTC)
t.Run("older than the retention period", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
events.ClearDispatchedEvents()
deleteExpiredProjects(trashedAt.Add(TrashRetention + time.Hour))
db.AssertMissing(t, "projects", map[string]interface{}{"id": 45})
db.AssertMissing(t, "projects", map[string]interface{}{"id": 46})
db.AssertExists(t, "projects", map[string]interface{}{"id": 1}, false)
doers := purgedProjectDoers(t)
require.Len(t, doers, 2)
assert.Contains(t, doers, int64(45))
assert.Nil(t, doers[45], "the system purges")
})
t.Run("within the retention period", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
deleteExpiredProjects(trashedAt.Add(TrashRetention - time.Hour))
db.AssertExists(t, "projects", map[string]interface{}{"id": 45}, false)
})
t.Run("sub-project trashed earlier expires before its parent", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
u6 := &user.User{ID: 6}
now := time.Now()
// 27 -> 12 -> 25 -> 26
trashProjectForTest(t, 26, u6)
s := db.NewSession()
_, err := s.Table("projects").Where("id = ?", 26).Update(map[string]any{"deleted_at": now.Add(-TrashRetention - time.Hour)})
require.NoError(t, err)
require.NoError(t, s.Commit())
s.Close()
trashProjectForTest(t, 12, u6)
deleteExpiredProjects(now)
db.AssertMissing(t, "projects", map[string]interface{}{"id": 26})
db.AssertCount(t, "projects", builder.And(
builder.In("id", 12, 25),
builder.NotNull{"deleted_at"},
), 2)
})
}
func TestGetProjectCount(t *testing.T) {
t.Run("counts the project and its sub-projects", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
tasks, err := s.Where("project_id = ?", 1).Count(&Task{})
require.NoError(t, err)
count, err := GetProjectCount(s, &user.User{ID: 1}, 1)
require.NoError(t, err)
assert.Equal(t, int64(0), count.SubProjects)
assert.Equal(t, tasks, count.Tasks)
// Entries 1, 2 and 4, all by user 1
assert.Equal(t, int64(3), count.TimeEntries)
assert.Equal(t, int64(1), count.TimeEntryAuthors)
count, err = GetProjectCount(s, &user.User{ID: 6}, 27)
require.NoError(t, err)
assert.Equal(t, int64(3), count.SubProjects)
_, err = GetProjectCount(s, &user.User{ID: 2}, 1)
require.ErrorIs(t, err, ErrGenericForbidden{})
})
t.Run("skips time entries on trashed tasks", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Task 51 in project 1 is in the trash
_, err := s.Insert(&TimeEntry{
UserID: 3,
TaskID: 51,
StartTime: time.Now(),
})
require.NoError(t, err)
count, err := GetProjectCount(s, &user.User{ID: 1}, 1)
require.NoError(t, err)
assert.Equal(t, int64(3), count.TimeEntries)
assert.Equal(t, int64(1), count.TimeEntryAuthors)
})
t.Run("instance admin gets no count for trashed or missing projects", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
admin := setUpInstanceAdmin(t)
s := db.NewSession()
defer s.Close()
_, err := GetProjectCount(s, admin, 45)
require.ErrorAs(t, err, &ErrProjectIsInTrash{})
_, err = GetProjectCount(s, admin, 99999)
assert.True(t, IsErrProjectDoesNotExist(err))
})
t.Run("admin link share is forbidden", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
share := &LinkSharing{
ID: 3,
ProjectID: 3,
Permission: PermissionAdmin,
}
_, err := GetProjectCount(s, share, 3)
require.ErrorIs(t, err, ErrGenericForbidden{})
})
}
func TestTimeEntry_TrashedProject(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
u3 := &user.User{ID: 3}
result, _, _, err := (&TimeEntry{}).ReadAll(s, u3, "", 1, 50)
require.NoError(t, err)
inTrash := map[int64]bool{}
for _, e := range result.([]*TimeEntry) {
inTrash[e.ID] = e.InTrash
}
assert.True(t, inTrash[5])
assert.True(t, inTrash[6])
result, _, _, err = (&TimeEntry{}).ReadAll(s, &user.User{ID: 1}, "", 1, 50)
require.NoError(t, err)
entryIDs := []int64{}
for _, e := range result.([]*TimeEntry) {
entryIDs = append(entryIDs, e.ID)
}
assert.Contains(t, entryIDs, int64(1))
assert.NotContains(t, entryIDs, int64(5))
assert.NotContains(t, entryIDs, int64(6))
read := &TimeEntry{ID: 6}
can, _, err := read.CanRead(s, u3)
require.NoError(t, err)
assert.True(t, can)
assert.True(t, read.InTrash)
can, err = (&TimeEntry{ID: 5}).CanDelete(s, u3)
require.NoError(t, err)
assert.False(t, can)
db.AssertCount(t, "time_entries", builder.Eq{"project_id": 45}, 1)
}
func TestBuildOverview_SkipsTrashedProjects(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
overview, err := BuildOverview(s)
require.NoError(t, err)
// 46 fixtures, 45 and 46 trashed
assert.Equal(t, int64(44), overview.Projects)
}
func TestCleanupAfterProjectAccessLoss_KeepsTrashedProjectsWithAccess(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
_, err := s.Insert(&TaskAssginee{
TaskID: 54,
UserID: 1,
})
require.NoError(t, err)
// User 1 still owns trashed project 45 and its child 46
require.NoError(t, cleanupAfterProjectAccessLossForUsers(s, []int64{1}, []int64{45}))
require.NoError(t, s.Commit())
db.AssertExists(t, "task_assignees", map[string]interface{}{
"task_id": 54,
"user_id": 1,
}, false)
}
func TestProject_TrashedIdentifierStaysReserved(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
_, err := s.Table("projects").Where("id = ?", 45).Update(map[string]any{"identifier": "TRASHED"})
require.NoError(t, err)
err = CreateProject(s, &Project{
Title: "new",
Identifier: "trashed",
}, &user.User{ID: 1}, false, false)
assert.True(t, IsErrProjectIdentifierIsNotUnique(err))
}
func TestRepairOrphanedProjects_Trashed(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
_, err := s.ID(45).Unscoped().Delete(&Project{})
require.NoError(t, err)
_, err = RepairOrphanedProjects(s, false)
require.NoError(t, err)
require.NoError(t, s.Commit())
s2 := db.NewSession()
defer s2.Close()
repaired := &Project{}
has, err := s2.Unscoped().ID(46).Get(repaired)
require.NoError(t, err)
require.True(t, has)
assert.Equal(t, int64(0), repaired.parentID())
}
// Project.Delete trashes whole subtrees, so the project access query only has to
// drop trashed targets. Every path that adds a live project under a trashed one breaks that.
func assertNoLiveDescendantOfTrashedProject(t *testing.T) {
t.Helper()
s := db.NewSession()
defer s.Close()
violations := []*ProjectAncestor{}
err := s.Table("project_ancestors").
Select("project_ancestors.*").
Join("INNER", []string{"projects", "ancestor"}, "ancestor.id = project_ancestors.ancestor_id").
Join("INNER", []string{"projects", "descendant"}, "descendant.id = project_ancestors.project_id").
Where("ancestor.deleted_at IS NOT NULL AND descendant.deleted_at IS NULL").
Find(&violations)
require.NoError(t, err)
assert.Empty(t, violations, "live projects below a trashed one")
}
func TestTrashedProjectNeverHasLiveDescendant(t *testing.T) {
u1 := &user.User{ID: 1}
u6 := &user.User{ID: 6}
t.Run("fixtures", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
assertNoLiveDescendantOfTrashedProject(t)
})
t.Run("trashing a project trashes its whole subtree", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
// 27 -> 12 -> 25 -> 26
trashProjectForTest(t, 12, u6)
assertNoLiveDescendantOfTrashedProject(t)
})
t.Run("restoring a sub-project of a trashed project is refused", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
require.Error(t, restoreProjectForTest(t, 46, u1))
assertNoLiveDescendantOfTrashedProject(t)
})
t.Run("restoring after a sub-project was trashed first", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
trashProjectForTest(t, 26, u6)
trashProjectForTest(t, 12, u6)
require.Error(t, restoreProjectForTest(t, 26, u6))
assertNoLiveDescendantOfTrashedProject(t)
require.NoError(t, restoreProjectForTest(t, 12, u6))
assertNoLiveDescendantOfTrashedProject(t)
})
doers := []struct {
name string
auth func(t *testing.T) web.Auth
}{
{"owner", func(*testing.T) web.Auth { return u1 }},
{"instance admin", func(t *testing.T) web.Auth { return setUpInstanceAdmin(t) }},
}
for _, doer := range doers {
t.Run("creating a project below a trashed one is refused for the "+doer.name, func(t *testing.T) {
db.LoadAndAssertFixtures(t)
a := doer.auth(t)
s := db.NewSession()
defer s.Close()
err := CreateProject(s, &Project{Title: "below the trash", ParentProjectID: Ptr(int64(45))}, a, false, false)
require.Error(t, err)
require.NoError(t, s.Commit())
assertNoLiveDescendantOfTrashedProject(t)
})
t.Run("moving a project below a trashed one is refused for the "+doer.name, func(t *testing.T) {
db.LoadAndAssertFixtures(t)
a := doer.auth(t)
s := db.NewSession()
defer s.Close()
project, err := GetProjectSimpleByID(s, 1)
require.NoError(t, err)
project.ParentProjectID = Ptr(int64(45))
require.Error(t, UpdateProject(s, project, a, false))
require.NoError(t, s.Commit())
assertNoLiveDescendantOfTrashedProject(t)
})
}
}
func TestEmptyProjectTrash_OwnDeletionsOnly(t *testing.T) {
db.LoadAndAssertFixtures(t)
u1 := &user.User{ID: 1}
s := db.NewSession()
_, err := s.Table("projects").Where("id = ?", 45).Update(map[string]any{"deleted_by_id": 2})
require.NoError(t, err)
require.NoError(t, s.Commit())
s.Close()
trashProjectForTest(t, 1, u1)
cs := db.NewSession()
count, err := CountProjectTrash(cs, u1)
cs.Close()
require.NoError(t, err)
// 45 (user 2, with child 46) and 1 (user 1)
assert.Equal(t, &TrashCount{Total: 2, ByOthers: 1}, count)
es := db.NewSession()
defer es.Close()
purged, err := EmptyProjectTrash(es, u1, false)
require.NoError(t, err)
require.NoError(t, es.Commit())
assert.Equal(t, 1, purged)
db.AssertMissing(t, "projects", map[string]interface{}{"id": 1})
db.AssertExists(t, "projects", map[string]interface{}{"id": 45}, false)
db.AssertExists(t, "projects", map[string]interface{}{"id": 46}, false)
}
func TestPurgeProject_KeepsTimeEntries(t *testing.T) {
u1 := &user.User{ID: 1}
// 5 is on project 45, 6 on its task 53
assertDetached := func(t *testing.T) {
t.Helper()
db.AssertMissing(t, "projects", map[string]interface{}{"id": 45})
for _, id := range []int64{
5,
6,
} {
db.AssertExists(t, "time_entries", map[string]interface{}{
"id": id,
"task_id": 0,
"project_id": 0,
}, false)
}
}
t.Run("purged directly", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
project := &Project{ID: 45}
can, err := project.CanDeletePermanently(s, u1)
require.NoError(t, err)
require.True(t, can)
require.NoError(t, project.DeletePermanently(s, u1))
require.NoError(t, s.Commit())
assertDetached(t)
})
t.Run("emptying the trash", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
_, err := EmptyProjectTrash(s, u1, true)
require.NoError(t, err)
require.NoError(t, s.Commit())
assertDetached(t)
})
t.Run("expired", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
deleteExpiredProjects(time.Now())
assertDetached(t)
})
}