feat(projects): don't allow deleting or archiving the default project
continuous-integration/drone/push Build is passing Details

This commit is contained in:
kolaente 2023-06-07 21:29:46 +02:00
parent ad0690369f
commit ef94e0cf86
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
10 changed files with 143 additions and 11 deletions

View File

@ -54,17 +54,19 @@ This document describes the different errors Vikunja can return.
## Project
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-----------------------------------------------------------------------------------------------------------------------------------------|
| 3001 | 404 | The project does not exist. |
| 3004 | 403 | The user needs to have read permissions on that project to perform that action. |
| 3005 | 400 | The project title cannot be empty. |
| 3006 | 404 | The project share does not exist. |
| 3007 | 400 | A project with this identifier already exists. |
| 3008 | 412 | The project is archived and can therefore only be accessed read only. This is also true for all tasks associated with this project. |
| 3009 | 412 | The project cannot belong to a dynamically generated parent project like "Favorites". |
| 3010 | 412 | This project cannot be a child of itself. |
| 3011 | 412 | This project cannot have a cyclic relationship to a parent project. |
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|-------------------------------------------------------------------------------------------------------------------------------------|
| 3001 | 404 | The project does not exist. |
| 3004 | 403 | The user needs to have read permissions on that project to perform that action. |
| 3005 | 400 | The project title cannot be empty. |
| 3006 | 404 | The project share does not exist. |
| 3007 | 400 | A project with this identifier already exists. |
| 3008 | 412 | The project is archived and can therefore only be accessed read only. This is also true for all tasks associated with this project. |
| 3009 | 412 | The project cannot belong to a dynamically generated parent project like "Favorites". |
| 3010 | 412 | This project cannot be a child of itself. |
| 3011 | 412 | This project cannot have a cyclic relationship to a parent project. |
| 3012 | 412 | This project cannot be deleted because a user has set it as their default project. |
| 3013 | 412 | This project cannot be archived because a user has set it as their default project. |
## Task

View File

@ -12,6 +12,7 @@
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234
email: 'user2@example.com'
issuer: local
default_project_id: 4
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
-
@ -20,6 +21,7 @@
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234
email: 'user3@example.com'
issuer: local
default_project_id: 4
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
-

View File

@ -347,6 +347,60 @@ func (err *ErrProjectCannotHaveACyclicRelationship) HTTPError() web.HTTPError {
}
}
// ErrCannotDeleteDefaultProject represents an error where the default project is being deleted
type ErrCannotDeleteDefaultProject struct {
ProjectID int64
}
// IsErrCannotDeleteDefaultProject checks if an error is a project is archived error.
func IsErrCannotDeleteDefaultProject(err error) bool {
_, ok := err.(*ErrCannotDeleteDefaultProject)
return ok
}
func (err *ErrCannotDeleteDefaultProject) Error() string {
return fmt.Sprintf("Default project cannot be deleted [ProjectID: %d]", err.ProjectID)
}
// ErrCodeCannotDeleteDefaultProject holds the unique world-error code of this error
const ErrCodeCannotDeleteDefaultProject = 3012
// HTTPError holds the http error description
func (err *ErrCannotDeleteDefaultProject) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusPreconditionFailed,
Code: ErrCodeCannotDeleteDefaultProject,
Message: "This project cannot be deleted because it is the default project of a user.",
}
}
// ErrCannotArchiveDefaultProject represents an error where the default project is being deleted
type ErrCannotArchiveDefaultProject struct {
ProjectID int64
}
// IsErrCannotArchiveDefaultProject checks if an error is a project is archived error.
func IsErrCannotArchiveDefaultProject(err error) bool {
_, ok := err.(*ErrCannotArchiveDefaultProject)
return ok
}
func (err *ErrCannotArchiveDefaultProject) Error() string {
return fmt.Sprintf("Default project cannot be archived [ProjectID: %d]", err.ProjectID)
}
// ErrCodeCannotArchiveDefaultProject holds the unique world-error code of this error
const ErrCodeCannotArchiveDefaultProject = 3013
// HTTPError holds the http error description
func (err *ErrCannotArchiveDefaultProject) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusPreconditionFailed,
Code: ErrCodeCannotArchiveDefaultProject,
Message: "This project cannot be archived because it is the default project of a user.",
}
}
// ==============
// Task errors
// ==============

View File

@ -44,6 +44,7 @@ func TestLabelTask_ReadAll(t *testing.T) {
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
DefaultProjectID: 4,
Created: testCreatedTime,
Updated: testUpdatedTime,
},

View File

@ -106,6 +106,7 @@ func TestLabel_ReadAll(t *testing.T) {
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
DefaultProjectID: 4,
Created: testCreatedTime,
Updated: testUpdatedTime,
},
@ -233,6 +234,7 @@ func TestLabel_ReadOne(t *testing.T) {
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
DefaultProjectID: 4,
Created: testCreatedTime,
Updated: testUpdatedTime,
},

View File

@ -739,6 +739,17 @@ func UpdateProject(s *xorm.Session, project *Project, auth web.Auth, updateProje
return
}
if project.IsArchived {
isDefaultProject, err := project.isDefaultProject(s)
if err != nil {
return err
}
if isDefaultProject {
return &ErrCannotArchiveDefaultProject{ProjectID: project.ID}
}
}
// We need to specify the cols we want to update here to be able to un-archive projects
colsToUpdate := []string{
"title",
@ -907,6 +918,12 @@ func (p *Project) Create(s *xorm.Session, a web.Auth) (err error) {
return p.ReadOne(s, a)
}
func (p *Project) isDefaultProject(s *xorm.Session) (is bool, err error) {
return s.
Where("default_project_id = ?", p.ID).
Exist(&user.User{})
}
// Delete implements the delete method of CRUDable
// @Summary Deletes a project
// @Description Delets a project
@ -921,6 +938,14 @@ func (p *Project) Create(s *xorm.Session, a web.Auth) (err error) {
// @Router /projects/{id} [delete]
func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) {
isDefaultProject, err := p.isDefaultProject(s)
if err != nil {
return err
}
if isDefaultProject {
return &ErrCannotDeleteDefaultProject{ProjectID: p.ID}
}
// Delete all tasks on that project
// Using the loop to make sure all related entities to all tasks are properly deleted as well.
tasks, _, _, err := getRawTasksForProjects(s, []*Project{p}, a, &taskOptions{})

View File

@ -219,6 +219,28 @@ func TestProject_CreateOrUpdate(t *testing.T) {
assert.True(t, IsErrProjectCannotBelongToAPseudoParentProject(err))
})
})
t.Run("archive default project of the same user", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
project := Project{
ID: 4,
IsArchived: true,
}
err := project.Update(s, &user.User{ID: 3})
assert.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()
project := Project{
ID: 4,
IsArchived: true,
}
err := project.Update(s, &user.User{ID: 2})
assert.Error(t, err)
assert.True(t, IsErrCannotArchiveDefaultProject(err))
})
})
}
@ -255,6 +277,26 @@ func TestProject_Delete(t *testing.T) {
"id": 1,
})
})
t.Run("default project of the same user", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
project := Project{
ID: 4,
}
err := project.Delete(s, &user.User{ID: 3})
assert.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()
project := Project{
ID: 4,
}
err := project.Delete(s, &user.User{ID: 2})
assert.Error(t, err)
assert.True(t, IsErrCannotDeleteDefaultProject(err))
})
}
func TestProject_DeleteBackgroundFileIfExists(t *testing.T) {

View File

@ -166,6 +166,7 @@ func TestProjectUser_ReadAll(t *testing.T) {
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
DefaultProjectID: 4,
Created: testCreatedTime,
Updated: testUpdatedTime,
},

View File

@ -50,6 +50,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
DefaultProjectID: 4,
Created: testCreatedTime,
Updated: testUpdatedTime,
}

View File

@ -44,6 +44,7 @@ func TestListUsersFromProject(t *testing.T) {
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
DefaultProjectID: 4,
Created: testCreatedTime,
Updated: testUpdatedTime,
}
@ -55,6 +56,7 @@ func TestListUsersFromProject(t *testing.T) {
EmailRemindersEnabled: true,
OverdueTasksRemindersEnabled: true,
OverdueTasksRemindersTime: "09:00",
DefaultProjectID: 4,
Created: testCreatedTime,
Updated: testUpdatedTime,
}