Files
Shivansh Gargandwxiaoguang 640cd615c4 feat: restrict pull/issue creation on access basis (#40069)
Adds a repository-level **"Restrict to collaborators"** option under the
Issues and Pull Request unit settings. When enabled, only users with
collaborator access to the base repository (owner, team members, or
explicit collaborators) can open issues or pull requests against it.

Public repositories often need to be visible/cloneable by everyone, but
owners may want to limit who can open pull requests (e.g. restrict to an
internal team while still keeping the code public)

* Fixes: https://github.com/go-gitea/gitea/issues/40015
* Fixes: https://github.com/go-gitea/gitea/issues/31890
* Fixes: https://github.com/go-gitea/gitea/issues/28021
* Fixes: https://github.com/go-gitea/gitea/issues/14801

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-11-10 00:53:49 +00:00

287 lines
11 KiB
Go

// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"net/url"
"strings"
"testing"
"time"
auth_model "gitea.dev/models/auth"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git"
pull_service "gitea.dev/services/pull"
repo_service "gitea.dev/services/repository"
files_service "gitea.dev/services/repository/files"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAPIPullUpdate(t *testing.T) {
onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
// Create PR to test
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
org26 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26})
pr := createOutdatedPR(t, user, org26)
require.NoError(t, pr.LoadBaseRepo(t.Context()))
require.NoError(t, pr.LoadIssue(t.Context()))
// Test GetDiverging
diffCount, err := git.GetDivergingCommits(t.Context(), pr.BaseRepo, pr.BaseBranch, pr.GetGitHeadRefName())
require.NoError(t, err)
assert.Equal(t, 1, diffCount.Behind)
assert.Equal(t, 1, diffCount.Ahead)
assert.Equal(t, diffCount.Behind, pr.CommitsBehind)
assert.Equal(t, diffCount.Ahead, pr.CommitsAhead)
session := loginUser(t, "user2")
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/pulls/%d/update", pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Issue.Index).
AddTokenAuth(token)
session.MakeRequest(t, req, http.StatusOK)
// Test GetDiverging after update
diffCount, err = git.GetDivergingCommits(t.Context(), pr.BaseRepo, pr.BaseBranch, pr.GetGitHeadRefName())
require.NoError(t, err)
assert.Equal(t, 0, diffCount.Behind)
assert.Equal(t, 2, diffCount.Ahead)
assert.Eventually(t, func() bool {
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID})
return diffCount.Behind == pr.CommitsBehind && diffCount.Ahead == pr.CommitsAhead
}, 5*time.Second, 20*time.Millisecond)
})
}
func updateRepoPullRequestConfig(t *testing.T, repoID int64, update func(*repo_model.PullRequestsConfig)) {
t.Helper()
repoUnit := unittest.AssertExistsAndLoadBean(t, &repo_model.RepoUnit{RepoID: repoID, Type: unit.TypePullRequests})
update(repoUnit.PullRequestsConfig())
assert.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoUnit))
}
func enableRepoAllowUpdateWithRebase(t *testing.T, repoID int64, allow bool) {
updateRepoPullRequestConfig(t, repoID, func(c *repo_model.PullRequestsConfig) { c.AllowRebaseUpdate = allow })
}
// setupOutdatedPRWithConfig creates an outdated PR (user2 base, org26 fork), loads its
// base repo, head repo, and issue, then applies the supplied PullRequestsConfig mutation.
func setupOutdatedPRWithConfig(t *testing.T, mutate func(*repo_model.PullRequestsConfig)) *issues_model.PullRequest {
t.Helper()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
org26 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26})
pr := createOutdatedPR(t, user, org26)
require.NoError(t, pr.LoadBaseRepo(t.Context()))
require.NoError(t, pr.LoadHeadRepo(t.Context()))
require.NoError(t, pr.LoadIssue(t.Context()))
if mutate != nil {
updateRepoPullRequestConfig(t, pr.BaseRepo.ID, mutate)
}
return pr
}
func TestAPIPullUpdateByRebase(t *testing.T) {
onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
// Create PR to test
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
org26 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26})
pr := createOutdatedPR(t, user, org26)
assert.NoError(t, pr.LoadBaseRepo(t.Context()))
// Test GetDiverging
diffCount, err := git.GetDivergingCommits(t.Context(), pr.BaseRepo, pr.BaseBranch, pr.GetGitHeadRefName())
assert.NoError(t, err)
assert.Equal(t, 1, diffCount.Behind)
assert.Equal(t, 1, diffCount.Ahead)
assert.NoError(t, pr.LoadIssue(t.Context()))
enableRepoAllowUpdateWithRebase(t, pr.BaseRepo.ID, false)
session := loginUser(t, "user2")
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/pulls/%d/update?style=rebase", pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Issue.Index).
AddTokenAuth(token)
session.MakeRequest(t, req, http.StatusForbidden)
enableRepoAllowUpdateWithRebase(t, pr.BaseRepo.ID, true)
assert.NoError(t, pr.LoadHeadRepo(t.Context()))
// use a user which have write access to the pr but not write permission to the head repository to do the rebase
user40 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 40})
err = repo_service.AddOrUpdateCollaborator(t.Context(), pr.BaseRepo, user40, perm.AccessModeWrite)
assert.NoError(t, err)
token40 := getUserToken(t, "user40", auth_model.AccessTokenScopeWriteRepository)
req = NewRequestf(t, "POST", "/api/v1/repos/%s/%s/pulls/%d/update?style=rebase", pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Issue.Index).
AddTokenAuth(token40)
session.MakeRequest(t, req, http.StatusForbidden)
err = repo_service.AddOrUpdateCollaborator(t.Context(), pr.HeadRepo, user40, perm.AccessModeWrite)
assert.NoError(t, err)
req = NewRequestf(t, "POST", "/api/v1/repos/%s/%s/pulls/%d/update?style=rebase", pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Issue.Index).
AddTokenAuth(token40)
session.MakeRequest(t, req, http.StatusOK)
// Test GetDiverging after update
diffCount, err = git.GetDivergingCommits(t.Context(), pr.BaseRepo, pr.BaseBranch, pr.GetGitHeadRefName())
assert.NoError(t, err)
assert.Equal(t, 0, diffCount.Behind)
assert.Equal(t, 1, diffCount.Ahead)
})
}
// TestAPIPullUpdateStyleSettings first checks that a disabled explicit style is
// forbidden, then guards back-compat: even when the repo's DefaultUpdateStyle is
// rebase, an API call with no `style` parameter must still perform a merge
// update so existing API clients don't silently flip behavior.
func TestAPIPullUpdateStyleSettings(t *testing.T) {
onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
pr := setupOutdatedPRWithConfig(t, func(c *repo_model.PullRequestsConfig) {
c.AllowMergeUpdate = false
c.AllowRebaseUpdate = true
c.DefaultUpdateStyle = repo_model.UpdateStyleRebase
})
user40 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 40})
require.NoError(t, repo_service.AddOrUpdateCollaborator(t.Context(), pr.BaseRepo, user40, perm.AccessModeWrite))
require.NoError(t, repo_service.AddOrUpdateCollaborator(t.Context(), pr.HeadRepo, user40, perm.AccessModeWrite))
session := loginUser(t, "user40")
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/pulls/%d/update?style=merge", pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Issue.Index).
AddTokenAuth(token)
session.MakeRequest(t, req, http.StatusForbidden)
updateRepoPullRequestConfig(t, pr.BaseRepo.ID, func(c *repo_model.PullRequestsConfig) {
c.AllowMergeUpdate = true
})
req = NewRequestf(t, "POST", "/api/v1/repos/%s/%s/pulls/%d/update", pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Issue.Index).
AddTokenAuth(token)
session.MakeRequest(t, req, http.StatusOK)
// merge update produces a merge commit on top of the head commit (Ahead=2),
// rebase update would replay the head commit alone (Ahead=1).
diffCount, err := git.GetDivergingCommits(t.Context(), pr.BaseRepo, pr.BaseBranch, pr.GetGitHeadRefName())
require.NoError(t, err)
assert.Equal(t, 0, diffCount.Behind)
assert.Equal(t, 2, diffCount.Ahead)
})
}
func createOutdatedPR(t *testing.T, actor, forkOrg *user_model.User) *issues_model.PullRequest {
baseRepo, err := repo_service.CreateRepository(t.Context(), actor, actor, repo_service.CreateRepoOptions{
Name: "repo-pr-update",
Description: "repo-tmp-pr-update description",
AutoInit: true,
Gitignores: "C,C++",
License: "MIT",
Readme: "Default",
IsPrivate: false,
})
assert.NoError(t, err)
assert.NotEmpty(t, baseRepo)
headRepo, err := repo_service.ForkRepository(t.Context(), actor, forkOrg, repo_service.ForkRepoOptions{
BaseRepo: baseRepo,
Name: "repo-pr-update",
Description: "desc",
})
assert.NoError(t, err)
assert.NotEmpty(t, headRepo)
// create a commit on base Repo
_, err = files_service.ChangeRepoFiles(t.Context(), baseRepo, actor, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "File_A",
ContentReader: strings.NewReader("File A"),
},
},
Message: "Add File A",
OldBranch: "master",
NewBranch: "master",
Author: &files_service.IdentityOptions{
GitUserName: actor.Name,
GitUserEmail: actor.Email,
},
Committer: &files_service.IdentityOptions{
GitUserName: actor.Name,
GitUserEmail: actor.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
})
assert.NoError(t, err)
// create a commit on head Repo
_, err = files_service.ChangeRepoFiles(t.Context(), headRepo, actor, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "File_B",
ContentReader: strings.NewReader("File B"),
},
},
Message: "Add File on PR branch",
OldBranch: "master",
NewBranch: "newBranch",
Author: &files_service.IdentityOptions{
GitUserName: actor.Name,
GitUserEmail: actor.Email,
},
Committer: &files_service.IdentityOptions{
GitUserName: actor.Name,
GitUserEmail: actor.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
})
assert.NoError(t, err)
// create Pull
pullIssue := &issues_model.Issue{
RepoID: baseRepo.ID,
Title: "Test Pull -to-update-",
PosterID: actor.ID,
Poster: actor,
IsPull: true,
}
pullRequest := &issues_model.PullRequest{
HeadRepoID: headRepo.ID,
BaseRepoID: baseRepo.ID,
HeadBranch: "newBranch",
BaseBranch: "master",
HeadRepo: headRepo,
BaseRepo: baseRepo,
Type: issues_model.PullRequestGitea,
}
prOpts := &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest}
basePerm, err := access_model.GetDoerRepoPermission(t.Context(), prOpts.Repo, prOpts.Issue.Poster)
require.NoError(t, err)
err = pull_service.NewPullRequest(t.Context(), basePerm, prOpts)
assert.NoError(t, err)
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "Test Pull -to-update-"})
assert.NoError(t, issue.LoadPullRequest(t.Context()))
return issue.PullRequest
}