// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package integration import ( "fmt" "net/http" "testing" auth_model "code.gitea.io/gitea/models/auth" issues_model "code.gitea.io/gitea/models/issues" project_model "code.gitea.io/gitea/models/project" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" issue_service "code.gitea.io/gitea/services/issue" projects_service "code.gitea.io/gitea/services/projects" "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" ) func TestAPIUserProjects(t *testing.T) { defer tests.PrepareTestEnv(t)() t.Run("ListProjects", testAPIUserListProjects) t.Run("GetProject", testAPIUserGetProject) t.Run("CreateProject", testAPIUserCreateProject) t.Run("UpdateProject", testAPIUserUpdateProject) t.Run("ChangeProjectStatus", testAPIUserChangeProjectStatus) t.Run("DeleteProject", testAPIUserDeleteProject) t.Run("ListProjectColumns", testAPIUserListProjectColumns) t.Run("CreateProjectColumn", testAPIUserCreateProjectColumn) t.Run("UpdateProjectColumn", testAPIUserUpdateProjectColumn) t.Run("DeleteProjectColumn", testAPIUserDeleteProjectColumn) t.Run("AddIssueToProjectColumn", testAPIUserAddIssueToProjectColumn) t.Run("RemoveIssueFromProjectColumn", testAPIUserRemoveIssueFromProjectColumn) t.Run("ListProjectColumnIssues", testAPIUserListProjectColumnIssues) t.Run("MoveProjectIssue", testAPIUserMoveProjectIssue) t.Run("MoveProjectIssueMultiProjectIsolation", testAPIUserMoveProjectIssueMultiProjectIsolation) t.Run("Permissions", testAPIUserProjectPermissions) } func makeUserProject(t *testing.T, owner *user_model.User) *project_model.Project { t.Helper() p := &project_model.Project{ OwnerID: owner.ID, Title: "Test User Project", Description: "created by test helper", CreatorID: owner.ID, Type: project_model.TypeIndividual, TemplateType: project_model.TemplateTypeNone, } err := project_model.NewProject(t.Context(), p) assert.NoError(t, err) return p } func testAPIUserListProjects(t *testing.T) { owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue) req := NewRequestf(t, "GET", "/api/v1/users/%s/projects", owner.Name).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) var projects []*api.Project DecodeJSON(t, resp, &projects) req = NewRequestf(t, "GET", "/api/v1/users/%s/projects?state=open", owner.Name).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &projects) for _, p := range projects { assert.Equal(t, api.StateOpen, p.State) } req = NewRequestf(t, "GET", "/api/v1/users/%s/projects?state=all", owner.Name).AddTokenAuth(token) MakeRequest(t, req, http.StatusOK) } func testAPIUserGetProject(t *testing.T) { owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) p := makeUserProject(t, owner) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue) req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) var got api.Project DecodeJSON(t, resp, &got) assert.Equal(t, p.ID, got.ID) assert.Equal(t, p.Title, got.Title) assert.Equal(t, "individual", got.Type) req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/99999", owner.Name).AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) } func testAPIUserCreateProject(t *testing.T) { owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects", owner.Name), &api.CreateProjectOption{ Title: "Created via API", Description: "desc", }).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusCreated) var got api.Project DecodeJSON(t, resp, &got) assert.Equal(t, "Created via API", got.Title) assert.Equal(t, "individual", got.Type) // unauthenticated req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects", owner.Name), &api.CreateProjectOption{Title: "x"}) MakeRequest(t, req, http.StatusUnauthorized) // empty title req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects", owner.Name), &api.CreateProjectOption{Title: ""}).AddTokenAuth(token) MakeRequest(t, req, http.StatusUnprocessableEntity) } func testAPIUserUpdateProject(t *testing.T) { owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) p := makeUserProject(t, owner) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) newTitle := "Updated Title" newDesc := "Updated desc" req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{ Title: &newTitle, Description: &newDesc, }).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) var got api.Project DecodeJSON(t, resp, &got) assert.Equal(t, newTitle, got.Title) assert.Equal(t, newDesc, got.Description) } func testAPIUserChangeProjectStatus(t *testing.T) { owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) p := makeUserProject(t, owner) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) closed := api.StateClosed req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{ State: &closed, }).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) var got api.Project DecodeJSON(t, resp, &got) assert.Equal(t, api.StateClosed, got.State) assert.NotNil(t, got.ClosedAt) open := api.StateOpen req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{ State: &open, }).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &got) assert.Equal(t, api.StateOpen, got.State) bogus := api.StateType("reopen") req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{ State: &bogus, }).AddTokenAuth(token) MakeRequest(t, req, http.StatusUnprocessableEntity) } func testAPIUserDeleteProject(t *testing.T) { owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) p := makeUserProject(t, owner) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) req := NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(token) MakeRequest(t, req, http.StatusNoContent) req = NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) } func testAPIUserListProjectColumns(t *testing.T) { owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) p := makeUserProject(t, owner) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue) for i := 1; i <= 3; i++ { col := &project_model.Column{ Title: fmt.Sprintf("Col%d", i), ProjectID: p.ID, CreatorID: owner.ID, } assert.NoError(t, project_model.NewColumn(t.Context(), col)) } req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) var cols []*api.ProjectColumn DecodeJSON(t, resp, &cols) assert.Len(t, cols, 3) assert.Equal(t, "3", resp.Header().Get("X-Total-Count")) req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns?page=1&limit=2", owner.Name, p.ID).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &cols) assert.Len(t, cols, 2) assert.Equal(t, "3", resp.Header().Get("X-Total-Count")) req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns?page=2&limit=2", owner.Name, p.ID).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &cols) assert.Len(t, cols, 1) assert.Equal(t, "3", resp.Header().Get("X-Total-Count")) } func testAPIUserCreateProjectColumn(t *testing.T) { owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) p := makeUserProject(t, owner) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID), &api.CreateProjectColumnOption{ Title: "New Column", Color: "#FF5733", }).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusCreated) var col api.ProjectColumn DecodeJSON(t, resp, &col) assert.Equal(t, "New Column", col.Title) assert.Equal(t, "#FF5733", col.Color) assert.Equal(t, p.ID, col.ProjectID) req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID), &api.CreateProjectColumnOption{ Title: "Simple Column", }).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusCreated) DecodeJSON(t, resp, &col) assert.Equal(t, "Simple Column", col.Title) // empty title req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID), &api.CreateProjectColumnOption{ Title: "", }).AddTokenAuth(token) MakeRequest(t, req, http.StatusUnprocessableEntity) // non-existent project req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/99999/columns", owner.Name), &api.CreateProjectColumnOption{ Title: "Orphan", }).AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) } func testAPIUserUpdateProjectColumn(t *testing.T) { owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) p := makeUserProject(t, owner) col := &project_model.Column{ Title: "Original", ProjectID: p.ID, CreatorID: owner.ID, Color: "#000000", } assert.NoError(t, project_model.NewColumn(t.Context(), col)) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) newTitle := "Updated Column" req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID), &api.EditProjectColumnOption{ Title: &newTitle, }).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) var got api.ProjectColumn DecodeJSON(t, resp, &got) assert.Equal(t, newTitle, got.Title) newColor := "#FF0000" req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID), &api.EditProjectColumnOption{ Color: &newColor, }).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &got) assert.Equal(t, newColor, got.Color) // non-existent column req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/99999", owner.Name, p.ID), &api.EditProjectColumnOption{ Title: &newTitle, }).AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) } func testAPIUserDeleteProjectColumn(t *testing.T) { owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) p := makeUserProject(t, owner) col := &project_model.Column{ Title: "ToDelete", ProjectID: p.ID, CreatorID: owner.ID, } assert.NoError(t, project_model.NewColumn(t.Context(), col)) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) req := NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID).AddTokenAuth(token) MakeRequest(t, req, http.StatusNoContent) req = NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d/columns/%d", owner.Name, p.ID, col.ID).AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) } func testAPIUserAddIssueToProjectColumn(t *testing.T) { owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) p := makeUserProject(t, owner) col1 := &project_model.Column{Title: "Column 1", ProjectID: p.ID, CreatorID: owner.ID} assert.NoError(t, project_model.NewColumn(t.Context(), col1)) col2 := &project_model.Column{Title: "Column 2", ProjectID: p.ID, CreatorID: owner.ID} assert.NoError(t, project_model.NewColumn(t.Context(), col2)) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) // add to col1 req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col1.ID, issue.ID), nil, ).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID}) assert.Equal(t, col1.ID, pi.ProjectColumnID) // move to col2 via POST req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col2.ID, issue.ID), nil, ).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) pi = unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID}) assert.Equal(t, col2.ID, pi.ProjectColumnID) // idempotent: add to same column again req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col2.ID, issue.ID), nil, ).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) // non-existent issue req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/99999", owner.Name, p.ID, col1.ID), nil, ).AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) // non-existent column req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/99999/issues/%d", owner.Name, p.ID, issue.ID), nil, ).AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) } func testAPIUserRemoveIssueFromProjectColumn(t *testing.T) { owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) p := makeUserProject(t, owner) col := &project_model.Column{Title: "Col", ProjectID: p.ID, CreatorID: owner.ID} assert.NoError(t, project_model.NewColumn(t.Context(), col)) otherCol := &project_model.Column{Title: "Other", ProjectID: p.ID, CreatorID: owner.ID} assert.NoError(t, project_model.NewColumn(t.Context(), otherCol)) assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{p.ID})) assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, col, map[int64]int64{0: issue.ID})) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) // removing via the wrong column must 404 and not detach the issue req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, otherCol.ID, issue.ID), nil, ).AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID}) // correct column fully detaches the issue from the project req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/users/%s/projects/%d/columns/%d/issues/%d", owner.Name, p.ID, col.ID, issue.ID), nil, ).AddTokenAuth(token) MakeRequest(t, req, http.StatusNoContent) unittest.AssertNotExistsBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID}) } func testAPIUserListProjectColumnIssues(t *testing.T) { owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) issueA := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) issueB := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) p := makeUserProject(t, owner) colA := &project_model.Column{Title: "ColA", ProjectID: p.ID, CreatorID: owner.ID} assert.NoError(t, project_model.NewColumn(t.Context(), colA)) colB := &project_model.Column{Title: "ColB", ProjectID: p.ID, CreatorID: owner.ID} assert.NoError(t, project_model.NewColumn(t.Context(), colB)) assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issueA, owner, []int64{p.ID})) assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colA, map[int64]int64{0: issueA.ID})) assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issueB, owner, []int64{p.ID})) assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colB, map[int64]int64{0: issueB.ID})) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeReadIssue) // colA contains only issueA req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues", owner.Name, p.ID, colA.ID).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) var gotA []api.Issue DecodeJSON(t, resp, &gotA) assert.Len(t, gotA, 1) assert.Equal(t, issueA.ID, gotA[0].ID) // colB contains only issueB req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues", owner.Name, p.ID, colB.ID).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) var gotB []api.Issue DecodeJSON(t, resp, &gotB) assert.Len(t, gotB, 1) assert.Equal(t, issueB.ID, gotB[0].ID) // Close issueA, then exercise the state filter (issue #4). assert.NoError(t, issue_service.CloseIssue(t.Context(), issueA, owner, "")) // default (state omitted) -> open only -> colA has nothing req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues", owner.Name, p.ID, colA.ID).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) var openOnly []api.Issue DecodeJSON(t, resp, &openOnly) assert.Empty(t, openOnly) // state=closed -> colA returns issueA req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues?state=closed", owner.Name, p.ID, colA.ID).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) var closedOnly []api.Issue DecodeJSON(t, resp, &closedOnly) assert.Len(t, closedOnly, 1) assert.Equal(t, issueA.ID, closedOnly[0].ID) // state=all -> colA returns issueA req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns/%d/issues?state=all", owner.Name, p.ID, colA.ID).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) var all []api.Issue DecodeJSON(t, resp, &all) assert.Len(t, all, 1) // Columns endpoint must populate num_issues / num_open_issues / num_closed_issues (issue #5). req = NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d/columns", owner.Name, p.ID).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusOK) var listed []*api.ProjectColumn DecodeJSON(t, resp, &listed) byID := map[int64]*api.ProjectColumn{} for _, c := range listed { byID[c.ID] = c } if assert.NotNil(t, byID[colA.ID]) { assert.EqualValues(t, 1, byID[colA.ID].NumIssues) assert.EqualValues(t, 0, byID[colA.ID].NumOpenIssues) assert.EqualValues(t, 1, byID[colA.ID].NumClosedIssues) } if assert.NotNil(t, byID[colB.ID]) { assert.EqualValues(t, 1, byID[colB.ID].NumIssues) assert.EqualValues(t, 1, byID[colB.ID].NumOpenIssues) assert.EqualValues(t, 0, byID[colB.ID].NumClosedIssues) } } func testAPIUserMoveProjectIssue(t *testing.T) { owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) p := makeUserProject(t, owner) colA := &project_model.Column{Title: "A", ProjectID: p.ID, CreatorID: owner.ID} assert.NoError(t, project_model.NewColumn(t.Context(), colA)) colB := &project_model.Column{Title: "B", ProjectID: p.ID, CreatorID: owner.ID} assert.NoError(t, project_model.NewColumn(t.Context(), colB)) assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{p.ID})) assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, colA, map[int64]int64{0: issue.ID})) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) // move to colB req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p.ID, issue.ID), &api.MoveProjectIssueOption{ColumnID: colB.ID}, ).AddTokenAuth(token) MakeRequest(t, req, http.StatusNoContent) pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p.ID, IssueID: issue.ID}) assert.Equal(t, colB.ID, pi.ProjectColumnID) // non-existent target column -> 422 req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p.ID, issue.ID), &api.MoveProjectIssueOption{ColumnID: 99999}, ).AddTokenAuth(token) MakeRequest(t, req, http.StatusUnprocessableEntity) // issue not in project -> 404 otherIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4}) req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p.ID, otherIssue.ID), &api.MoveProjectIssueOption{ColumnID: colA.ID}, ).AddTokenAuth(token) MakeRequest(t, req, http.StatusNotFound) } // Regression for #17: moving an issue's column in one user project must not // rewrite its column in other projects the issue also belongs to. func testAPIUserMoveProjectIssueMultiProjectIsolation(t *testing.T) { owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) p1 := makeUserProject(t, owner) p2 := makeUserProject(t, owner) p1ColA := &project_model.Column{Title: "p1-A", ProjectID: p1.ID, CreatorID: owner.ID} assert.NoError(t, project_model.NewColumn(t.Context(), p1ColA)) p1ColB := &project_model.Column{Title: "p1-B", ProjectID: p1.ID, CreatorID: owner.ID} assert.NoError(t, project_model.NewColumn(t.Context(), p1ColB)) p2Col := &project_model.Column{Title: "p2", ProjectID: p2.ID, CreatorID: owner.ID} assert.NoError(t, project_model.NewColumn(t.Context(), p2Col)) assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{p1.ID, p2.ID})) assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, p1ColA, map[int64]int64{0: issue.ID})) assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, p2Col, map[int64]int64{0: issue.ID})) token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) // move the issue inside p1 only req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p1.ID, issue.ID), &api.MoveProjectIssueOption{ColumnID: p1ColB.ID}, ).AddTokenAuth(token) MakeRequest(t, req, http.StatusNoContent) // p1 updated as requested pi1 := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p1.ID, IssueID: issue.ID}) assert.Equal(t, p1ColB.ID, pi1.ProjectColumnID) // p2 must be untouched pi2 := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p2.ID, IssueID: issue.ID}) assert.Equal(t, p2Col.ID, pi2.ProjectColumnID, "issue must remain in its original column in other projects") } func testAPIUserProjectPermissions(t *testing.T) { owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) other := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"}) admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) p := makeUserProject(t, owner) ownerToken := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue) otherToken := getUserToken(t, other.Name, auth_model.AccessTokenScopeWriteIssue) adminToken := getUserToken(t, admin.Name, auth_model.AccessTokenScopeWriteIssue) // anon can read req := NewRequestf(t, "GET", "/api/v1/users/%s/projects/%d", owner.Name, p.ID) MakeRequest(t, req, http.StatusOK) // owner can write newTitle := "By Owner" req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{ Title: &newTitle, }).AddTokenAuth(ownerToken) MakeRequest(t, req, http.StatusOK) // other user cannot write newTitle = "By Other" req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{ Title: &newTitle, }).AddTokenAuth(otherToken) MakeRequest(t, req, http.StatusForbidden) // other user cannot delete req = NewRequestf(t, "DELETE", "/api/v1/users/%s/projects/%d", owner.Name, p.ID).AddTokenAuth(otherToken) MakeRequest(t, req, http.StatusForbidden) // admin can write newTitle = "By Admin" req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/users/%s/projects/%d", owner.Name, p.ID), &api.EditProjectOption{ Title: &newTitle, }).AddTokenAuth(adminToken) MakeRequest(t, req, http.StatusOK) }