// 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 models import ( "reflect" "testing" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/user" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestProject_CreateOrUpdate(t *testing.T) { usr := &user.User{ ID: 1, Username: "user1", Email: "user1@example.com", } t.Run("create", func(t *testing.T) { t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ Title: "test", Description: "Lorem Ipsum", } err := project.Create(s, usr) require.NoError(t, err) err = s.Commit() require.NoError(t, err) db.AssertExists(t, "projects", map[string]interface{}{ "id": project.ID, "title": project.Title, "description": project.Description, "parent_project_id": 0, }, false) db.AssertExists(t, "project_views", map[string]interface{}{ "project_id": project.ID, "view_kind": ProjectViewKindList, }, false) db.AssertExists(t, "project_views", map[string]interface{}{ "project_id": project.ID, "view_kind": ProjectViewKindGantt, }, false) db.AssertExists(t, "project_views", map[string]interface{}{ "project_id": project.ID, "view_kind": ProjectViewKindTable, }, false) db.AssertExists(t, "project_views", map[string]interface{}{ "project_id": project.ID, "view_kind": ProjectViewKindKanban, "bucket_configuration_mode": BucketConfigurationModeManual, }, false) kanbanView := &ProjectView{} _, err = s.Where("project_id = ? AND view_kind = ?", project.ID, ProjectViewKindKanban).Get(kanbanView) require.NoError(t, err) db.AssertExists(t, "buckets", map[string]interface{}{ "project_view_id": kanbanView.ID, }, false) }) t.Run("kanban view creates To-Do, doing, done buckets", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ Title: "test kanban buckets", Description: "Lorem Ipsum", } err := project.Create(s, usr) require.NoError(t, err) err = s.Commit() require.NoError(t, err) // Get the kanban view kanbanView := &ProjectView{} _, err = s.Where("project_id = ? AND view_kind = ?", project.ID, ProjectViewKindKanban).Get(kanbanView) require.NoError(t, err) // Check that three buckets were created var bucketCount int64 bucketCount, err = s.Where("project_view_id = ?", kanbanView.ID).Count(&Bucket{}) require.NoError(t, err) assert.Equal(t, int64(3), bucketCount, "Should have created three buckets") // Check that the buckets are named correctly var buckets []*Bucket err = s.Where("project_view_id = ?", kanbanView.ID).OrderBy("position ASC").Find(&buckets) require.NoError(t, err) require.Len(t, buckets, 3, "Should have three buckets") assert.Equal(t, "To-Do", buckets[0].Title) assert.Equal(t, "Doing", buckets[1].Title) assert.Equal(t, "Done", buckets[2].Title) // Check that Backlog is the default bucket assert.Equal(t, buckets[0].ID, kanbanView.DefaultBucketID, "To-Do should be the default bucket") // Check that Done is the done bucket assert.Equal(t, buckets[2].ID, kanbanView.DoneBucketID, "Done should be the done bucket") }) t.Run("nonexistent parent", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ Title: "test", Description: "Lorem Ipsum", ParentProjectID: 999999, } err := project.Create(s, usr) require.Error(t, err) assert.True(t, IsErrProjectDoesNotExist(err)) }) t.Run("nonexistent owner", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() usr := &user.User{ID: 9482385} project := Project{ Title: "test", Description: "Lorem Ipsum", } err := project.Create(s, usr) require.Error(t, err) assert.True(t, user.IsErrUserDoesNotExist(err)) }) t.Run("existing identifier", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ Title: "test", Description: "Lorem Ipsum", Identifier: "test1", } err := project.Create(s, usr) require.Error(t, err) assert.True(t, IsErrProjectIdentifierIsNotUnique(err)) }) t.Run("non ascii characters", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ Title: "приффки фсем", Description: "Lorem Ipsum", } err := project.Create(s, usr) require.NoError(t, err) err = s.Commit() require.NoError(t, err) db.AssertExists(t, "projects", map[string]interface{}{ "id": project.ID, "title": project.Title, "description": project.Description, }, false) }) }) t.Run("update", func(t *testing.T) { t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ ID: 1, Title: "test", Description: "Lorem Ipsum", } project.Description = "Lorem Ipsum dolor sit amet." err := project.Update(s, usr) require.NoError(t, err) err = s.Commit() require.NoError(t, err) db.AssertExists(t, "projects", map[string]interface{}{ "id": project.ID, "title": project.Title, "description": project.Description, }, false) }) t.Run("nonexistent", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ ID: 99999999, Title: "test", } err := project.Update(s, usr) require.Error(t, err) assert.True(t, IsErrProjectDoesNotExist(err)) }) t.Run("existing identifier", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ Title: "test", Description: "Lorem Ipsum", Identifier: "test1", } err := project.Create(s, usr) require.Error(t, err) assert.True(t, IsErrProjectIdentifierIsNotUnique(err)) }) t.Run("change parent project", func(t *testing.T) { t.Run("own", func(t *testing.T) { usr := &user.User{ ID: 6, Username: "user6", Email: "user6@example.com", } db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ ID: 6, Title: "Test6", Description: "Lorem Ipsum", ParentProjectID: 7, // from 6 } can, err := project.CanUpdate(s, usr) require.NoError(t, err) assert.True(t, can) err = project.Update(s, usr) require.NoError(t, err) err = s.Commit() require.NoError(t, err) db.AssertExists(t, "projects", map[string]interface{}{ "id": project.ID, "title": project.Title, "description": project.Description, "parent_project_id": project.ParentProjectID, }, false) }) t.Run("others", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ ID: 1, Title: "Test1", Description: "Lorem Ipsum", ParentProjectID: 2, // from 1 } can, _ := project.CanUpdate(s, usr) assert.False(t, can) // project is not writeable by us }) t.Run("pseudo project", func(t *testing.T) { usr := &user.User{ ID: 6, Username: "user6", Email: "user6@example.com", } db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ ID: 6, Title: "Test6", Description: "Lorem Ipsum", ParentProjectID: -1, } err := project.Update(s, usr) require.Error(t, err) assert.True(t, IsErrProjectCannotBelongToAPseudoParentProject(err)) }) t.Run("attacker with direct Write on victim project cannot reparent it (GHSA-2vq4-854f-5c72)", func(t *testing.T) { // User 1 has direct Write on project 10 (owner=6) via // users_projects id=4 and owns root project 1. Pre-fix, a // reparent of 10 under 1 passed the CanWrite check and the // CTE then cascaded Admin on 10 via ownership of the new // parent. db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ ID: 10, Title: "Test10", ParentProjectID: 1, // attacker-owned root } err := project.Update(s, usr) require.Error(t, err) assert.True(t, IsErrGenericForbidden(err)) }) t.Run("attacker with inherited Write cannot reparent child to attacker root (GHSA-2vq4-854f-5c72)", func(t *testing.T) { // User 1 has Write on project 10 and therefore inherits Write on its // child (project 43, added in the fixture above) via the CTE. User 1 // owns project 1. Reparenting 43 under 1 must be rejected. db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ ID: 43, Title: "Reparent Escalation Test Child", ParentProjectID: 1, } err := project.Update(s, usr) require.Error(t, err) assert.True(t, IsErrGenericForbidden(err)) }) t.Run("non-reparent update with Write still permitted (regression)", func(t *testing.T) { // User 1 has Write (not Admin) on project 43 via project 10; // a rename with parent unchanged must not trip the Admin gate. // // ParentProjectID is set to the stored value (10), not 0: the // gate only fires on non-zero ParentProjectID because the // generic handler can't distinguish omitted from explicit-zero // (detach-to-root is a follow-up, needs a pointer field). db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ ID: 43, Title: "Reparent Escalation Test Child renamed", ParentProjectID: 10, // unchanged — no reparent intent } err := project.Update(s, usr) require.NoError(t, err) }) }) t.Run("archive default project of the same user", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ ID: 4, IsArchived: true, } err := project.Update(s, &user.User{ID: 3}) require.Error(t, err) assert.True(t, IsErrCannotArchiveDefaultProject(err)) }) t.Run("archive default project of another user", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ ID: 4, IsArchived: true, } err := project.Update(s, &user.User{ID: 2}) require.Error(t, err) assert.True(t, IsErrCannotArchiveDefaultProject(err)) }) t.Run("archive parent archives child", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() actingUser := &user.User{ID: 6} projectToArchive := Project{ ID: 27, } // We need to load the project first to have its fields populated for the update can, err := projectToArchive.CanUpdate(s, actingUser) require.NoError(t, err, "Failed to read project 27 before archiving") assert.True(t, can) projectToArchive.IsArchived = true // Ensure IsArchived is set after reading err = projectToArchive.Update(s, actingUser) require.NoError(t, err, "Failed to archive project") err = s.Commit() require.NoError(t, err, "Failed to commit session after archiving project") db.AssertExists(t, "projects", map[string]interface{}{ "id": 27, "is_archived": true, }, false) // Assert child project (ID 12) is also archived db.AssertExists(t, "projects", map[string]interface{}{ "id": 12, "is_archived": true, }, false) }) }) } func TestProject_Delete(t *testing.T) { t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ ID: 1, } err := project.Delete(s, &user.User{ID: 1}) require.NoError(t, err) err = s.Commit() require.NoError(t, err) db.AssertMissing(t, "projects", map[string]interface{}{ "id": 1, }) db.AssertMissing(t, "tasks", map[string]interface{}{ "id": 1, }) }) t.Run("with background", func(t *testing.T) { db.LoadAndAssertFixtures(t) files.InitTestFileFixtures(t) s := db.NewSession() defer s.Close() project := Project{ ID: 35, } err := project.Delete(s, &user.User{ID: 6}) require.NoError(t, err) err = s.Commit() require.NoError(t, err) db.AssertMissing(t, "projects", map[string]interface{}{ "id": 35, }) db.AssertMissing(t, "files", map[string]interface{}{ "id": 1, }) }) t.Run("default project of the same user", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ ID: 4, } err := project.Delete(s, &user.User{ID: 3}) require.Error(t, err) assert.True(t, IsErrCannotDeleteDefaultProject(err)) }) t.Run("default project of a different user", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() project := Project{ ID: 4, } err := project.Delete(s, &user.User{ID: 2}) require.Error(t, err) assert.True(t, IsErrCannotDeleteDefaultProject(err)) }) t.Run("deletes archived parent and its child atomically", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() // Project 22 is archived (is_archived=1), owned by user 1 // Project 21 is a child of 22 (parent_project_id=22) project := Project{ID: 22} err := project.Delete(s, &user.User{ID: 1}) require.NoError(t, err) err = s.Commit() require.NoError(t, err) db.AssertMissing(t, "projects", map[string]interface{}{"id": 22}) db.AssertMissing(t, "projects", map[string]interface{}{"id": 21}) }) t.Run("deletes deeply nested child projects recursively", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() // Project hierarchy: 27 -> 12 -> 25 -> 26 (all owned by user 6) project := Project{ID: 27} err := project.Delete(s, &user.User{ID: 6}) require.NoError(t, err) err = s.Commit() require.NoError(t, err) db.AssertMissing(t, "projects", map[string]interface{}{"id": 27}) db.AssertMissing(t, "projects", map[string]interface{}{"id": 12}) db.AssertMissing(t, "projects", map[string]interface{}{"id": 25}) db.AssertMissing(t, "projects", map[string]interface{}{"id": 26}) }) } func TestProject_DeleteBackgroundFileIfExists(t *testing.T) { t.Run("project with background", func(t *testing.T) { db.LoadAndAssertFixtures(t) files.InitTestFileFixtures(t) s := db.NewSession() defer s.Close() file := &files.File{ID: 1} project := Project{ ID: 1, BackgroundFileID: file.ID, } err := SetProjectBackground(s, project.ID, file, "") require.NoError(t, err) err = project.DeleteBackgroundFileIfExists(s) require.NoError(t, err) }) t.Run("project with invalid background", func(t *testing.T) { db.LoadAndAssertFixtures(t) files.InitTestFileFixtures(t) s := db.NewSession() defer s.Close() file := &files.File{ID: 9999} project := Project{ ID: 1, BackgroundFileID: file.ID, } err := SetProjectBackground(s, project.ID, file, "") require.NoError(t, err) err = project.DeleteBackgroundFileIfExists(s) require.NoError(t, err) }) t.Run("project without background", func(t *testing.T) { db.LoadAndAssertFixtures(t) files.InitTestFileFixtures(t) s := db.NewSession() defer s.Close() project := Project{ID: 1} err := project.DeleteBackgroundFileIfExists(s) require.NoError(t, err) }) } func TestProject_ReadAll(t *testing.T) { t.Run("all", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() projects, _, err := getAllProjectsForUser(s, 6, &projectOptions{}) require.NoError(t, err) // +1 for the reparent-escalation fixture child (project 43, owner=6). assert.Len(t, projects, 28) }) t.Run("all projects for user", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() u := &user.User{ID: 1} project := Project{} projects3, _, _, err := project.ReadAll(s, u, "", 1, 50) require.NoError(t, err) assert.Equal(t, reflect.Slice, reflect.TypeOf(projects3).Kind()) ls := projects3.([]*Project) // +1 for the reparent-escalation fixture child (project 43) that // user 1 inherits Write on via project 10. assert.Len(t, ls, 28) assert.Equal(t, int64(3), ls[0].ID) // Project 3 has a position of 1 and should be sorted first assert.Equal(t, int64(1), ls[1].ID) assert.Equal(t, int64(6), ls[2].ID) assert.Equal(t, int64(-1), ls[26].ID) assert.Equal(t, int64(-2), ls[27].ID) }) t.Run("projects for nonexistent user", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() usr := &user.User{ID: 999999} project := Project{} _, _, _, err := project.ReadAll(s, usr, "", 1, 50) require.Error(t, err) assert.True(t, user.IsErrUserDoesNotExist(err)) }) t.Run("search", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() u := &user.User{ID: 1} project := Project{} projects3, _, _, err := project.ReadAll(s, u, "TEST10", 1, 50) require.NoError(t, err) ls := projects3.([]*Project) if db.ParadeDBAvailable() { // ParadeDB fuzzy(1, prefix=true) on "TEST10" also matches // "test1", "test11", "test19", "test30" (edit distance 1), etc. // The recursive CTE also pulls in project 43 as a child of the // matched project 10 (reparent-escalation fixture). require.Len(t, ls, 7) projectIDs := make([]int64, len(ls)) for i, p := range ls { projectIDs[i] = p.ID } assert.Contains(t, projectIDs, int64(10)) assert.Contains(t, projectIDs, int64(43)) assert.Contains(t, projectIDs, int64(-1)) } else { // Expect project 10 (the search target), project 43 (its child — // reparent-escalation fixture, pulled in as a descendant so tree // navigation stays intact) and the favorites pseudo project -1. require.Len(t, ls, 3) projectIDs := make([]int64, len(ls)) for i, p := range ls { projectIDs[i] = p.ID } assert.Contains(t, projectIDs, int64(10)) assert.Contains(t, projectIDs, int64(43)) assert.Contains(t, projectIDs, int64(-1)) } }) t.Run("search returns filters as well", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() u := &user.User{ID: 1} project := Project{} projects3, _, _, err := project.ReadAll(s, u, "testfilter", 1, 50) require.NoError(t, err) ls := projects3.([]*Project) require.Len(t, ls, 2) assert.Equal(t, int64(-1), ls[0].ID) assert.Equal(t, int64(-2), ls[1].ID) }) t.Run("archived propagation aggregation", func(t *testing.T) { // Regression test for #2589. getAllProjectsForUser must: // 1. Expose inherited is_archived for child projects whose parent is archived // (exercises the MAX(...) AS is_archived column expression). // 2. Hide those inherited-archived rows when getArchived=false // (exercises the HAVING MAX(...) = 0 filter). // The CTE must use dialect-agnostic SQL — no CAST(... AS int), which MySQL 8 rejects. db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() findByID := func(ps []*Project, id int64) *Project { for _, p := range ps { if p.ID == id { return p } } return nil } // getArchived=true: project 21 (child of archived 22) must appear and carry is_archived=true. withArchived, _, err := getAllProjectsForUser(s, 1, &projectOptions{getArchived: true}) require.NoError(t, err) parent := findByID(withArchived, 22) require.NotNil(t, parent, "archived parent project 22 must be returned when getArchived=true") assert.True(t, parent.IsArchived, "project 22 is archived in fixtures") child := findByID(withArchived, 21) require.NotNil(t, child, "child project 21 must be returned when getArchived=true") assert.True(t, child.IsArchived, "project 21 must inherit is_archived from its archived parent (22)") // getArchived=false: both rows must be filtered out by the HAVING clause. withoutArchived, _, err := getAllProjectsForUser(s, 1, &projectOptions{getArchived: false}) require.NoError(t, err) assert.Nil(t, findByID(withoutArchived, 22), "archived project 22 must be filtered when getArchived=false") assert.Nil(t, findByID(withoutArchived, 21), "child of archived project (21) must be filtered when getArchived=false (inherited archived state)") // Sanity: a non-archived project owned by user 1 is still present in the filtered list. assert.NotNil(t, findByID(withoutArchived, 1), "non-archived project 1 must still be present when getArchived=false") }) } func TestProject_ReadOne(t *testing.T) { t.Run("normal", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() u := &user.User{ID: 1} l := &Project{ID: 1} can, _, err := l.CanRead(s, u) require.NoError(t, err) assert.True(t, can) err = l.ReadOne(s, u) require.NoError(t, err) assert.Equal(t, "Test1", l.Title) }) t.Run("with subscription", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() u := &user.User{ID: 6} l := &Project{ID: 12} can, _, err := l.CanRead(s, u) require.NoError(t, err) assert.True(t, can) err = l.ReadOne(s, u) require.NoError(t, err) assert.NotNil(t, l.Subscription) }) } func TestCheckIsArchived(t *testing.T) { t.Run("child project archived individually with non-archived parent", func(t *testing.T) { // Project 40 is archived individually (is_archived=true) but its parent // (project 1) is not archived. CheckIsArchived must still return an error. db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() p := &Project{ID: 40, ParentProjectID: 3} err := p.CheckIsArchived(s) require.Error(t, err) assert.True(t, IsErrProjectIsArchived(err)) }) t.Run("root project archived", func(t *testing.T) { // Project 22 is archived individually with no parent. db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() p := &Project{ID: 22} err := p.CheckIsArchived(s) require.Error(t, err) assert.True(t, IsErrProjectIsArchived(err)) }) t.Run("child project inherits archived from parent", func(t *testing.T) { // Project 21's parent (project 22) is archived. db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() p := &Project{ID: 21, ParentProjectID: 22} err := p.CheckIsArchived(s) require.Error(t, err) assert.True(t, IsErrProjectIsArchived(err)) }) t.Run("non-archived project", func(t *testing.T) { db.LoadAndAssertFixtures(t) s := db.NewSession() defer s.Close() p := &Project{ID: 1} err := p.CheckIsArchived(s) require.NoError(t, err) }) }