// Copyright 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: GPL-3.0-or-later package integration import ( "fmt" "net/http" "net/url" "strings" "testing" "time" actions_model "forgejo.org/models/actions" auth_model "forgejo.org/models/auth" issues_model "forgejo.org/models/issues" repo_model "forgejo.org/models/repo" unit_model "forgejo.org/models/unit" "forgejo.org/models/unittest" user_model "forgejo.org/models/user" actions_module "forgejo.org/modules/actions" "forgejo.org/modules/git" "forgejo.org/modules/structs" "forgejo.org/modules/translation" actions_service "forgejo.org/services/actions" pull_service "forgejo.org/services/pull" repo_service "forgejo.org/services/repository" files_service "forgejo.org/services/repository/files" "forgejo.org/tests" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func actionsTrustTestClickTrustPanel(t *testing.T, session *TestSession, url, trust string) { // an admin approves the run once req := NewRequest(t, "GET", url) resp := session.MakeRequest(t, req, http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) htmlDoc.AssertElement(t, "#pull-request-trust-panel", true) link, exists := htmlDoc.doc.Find("#pull-request-trust-panel-" + trust).Attr("action") require.True(t, exists) actualTrust, exists := htmlDoc.doc.Find(fmt.Sprintf("#pull-request-trust-panel-%s input[name='trust']", trust)).Attr("value") require.True(t, exists) require.Equal(t, trust, actualTrust) req = NewRequestWithValues(t, "POST", link, map[string]string{ "trust": trust, }) session.MakeRequest(t, req, http.StatusSeeOther) } func actionsTrustTestAssertTrustPanelPresence(t *testing.T, session *TestSession, url string, present bool) { t.Helper() req := NewRequest(t, "GET", url) resp := session.MakeRequest(t, req, http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) htmlDoc.AssertElement(t, ".error-code", false) htmlDoc.AssertElement(t, "#pull-request-trust-panel", present) } func actionsTrustTestAssertTrustPanel(t *testing.T, session *TestSession, url string) { t.Helper() actionsTrustTestAssertTrustPanelPresence(t, session, url, true) } func actionsTrustTestAssertNoTrustPanel(t *testing.T, session *TestSession, url string) { t.Helper() actionsTrustTestAssertTrustPanelPresence(t, session, url, false) } func actionsTrustTestAssertPRIsWIP(t *testing.T, session *TestSession, url string) { t.Helper() req := NewRequest(t, "GET", url) resp := session.MakeRequest(t, req, http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) locale := translation.NewLocale("en-US") assert.Equal(t, 1, htmlDoc.FindByTextTrim("div", locale.TrString("repo.pulls.cannot_merge_work_in_progress")).Length()) } func actionsTrustTestAssertPRConflicted(t *testing.T, session *TestSession, url string) { t.Helper() req := NewRequest(t, "GET", url) resp := session.MakeRequest(t, req, http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) locale := translation.NewLocale("en-US") // ....Eventually is used because conflict checking is async and may not complete immediately. require.Eventually(t, func() bool { return htmlDoc.FindByTextTrim("div", locale.TrString("repo.pulls.files_conflicted")).Length() == 1 }, 5*time.Second, time.Millisecond*100) } func actionsTrustTestCreateBaseRepo(t *testing.T, owner *user_model.User) (*repo_model.Repository, func()) { t.Helper() // create the base repo baseRepo, _, f := tests.CreateDeclarativeRepo(t, owner, "repo-pull-request", []unit_model.Type{unit_model.TypeActions}, nil, nil, ) // add workflow file to the base repo addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, owner, &files_service.ChangeRepoFilesOptions{ Files: []*files_service.ChangeRepoFile{ { Operation: "create", TreePath: ".forgejo/workflows/pr.yml", ContentReader: strings.NewReader(` on: pull_request: jobs: test: runs-on: docker steps: - run: echo helloworld `), }, }, Message: "add workflow", OldBranch: "main", NewBranch: "main", Author: &files_service.IdentityOptions{ Name: owner.Name, Email: owner.Email, }, Committer: &files_service.IdentityOptions{ Name: owner.Name, Email: owner.Email, }, Dates: &files_service.CommitDateOptions{ Author: time.Now(), Committer: time.Now(), }, }) require.NoError(t, err) require.NotEmpty(t, addWorkflowToBaseResp) return baseRepo, f } func actionsTrustTestRequireRun(t *testing.T, repo *repo_model.Repository, modifiedFiles *structs.FilesResponse) { t.Helper() actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID, CommitSHA: modifiedFiles.Commit.SHA}) require.Equal(t, actions_module.GithubEventPullRequest, actionRun.TriggerEvent) require.Equal(t, actions_model.StatusWaiting.String(), actionRun.Status.String()) unittest.BeanExists(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: repo.ID}) } func actionsTrustTestRepoCreateBranch(t *testing.T, doer *user_model.User, repo *repo_model.Repository) *structs.FilesResponse { t.Helper() return actionsTrustTestModifyRepo(t, doer, repo, "file_in_fork.txt", "main", "fork-branch-1", "content") } func actionsTrustMakePRConflicted(t *testing.T, doer *user_model.User, repo *repo_model.Repository) *structs.FilesResponse { t.Helper() return actionsTrustTestModifyRepo(t, doer, repo, "file_in_fork.txt", "main", "main", "conflicting content") } func actionsTrustTestRepoModify(t *testing.T, doer *user_model.User, baseRepo, headRepo *repo_model.Repository, filename string) *structs.FilesResponse { t.Helper() modified := actionsTrustTestModifyRepo(t, doer, headRepo, filename, "fork-branch-1", "fork-branch-1", "content") // the creation of the run is not synchronous require.Eventually(t, func() bool { return unittest.BeanExists(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: modified.Commit.SHA}) }, 60*time.Second, time.Millisecond*100) return modified } func actionsTrustTestModifyRepo(t *testing.T, doer *user_model.User, repo *repo_model.Repository, filename, oldBranch, newBranch, content string) *structs.FilesResponse { t.Helper() // add a new file to the forked repo addFile, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, &files_service.ChangeRepoFilesOptions{ Files: []*files_service.ChangeRepoFile{ { Operation: "create", TreePath: filename, ContentReader: strings.NewReader(content), }, }, Message: "add " + filename, OldBranch: oldBranch, NewBranch: newBranch, Author: &files_service.IdentityOptions{ Name: doer.Name, Email: doer.Email, }, Committer: &files_service.IdentityOptions{ Name: doer.Name, Email: doer.Email, }, Dates: &files_service.CommitDateOptions{ Author: time.Now(), Committer: time.Now(), }, }) require.NoError(t, err) require.NotEmpty(t, addFile) return addFile } func actionsTrustTestCreatePullRequestFromForkedRepo(t *testing.T, baseUser *user_model.User, baseRepo *repo_model.Repository, headUser *user_model.User) (*repo_model.Repository, *issues_model.PullRequest, *structs.FilesResponse) { t.Helper() forkRepo := func(t *testing.T, baseUser *user_model.User, baseRepo *repo_model.Repository, headUser *user_model.User) *repo_model.Repository { t.Helper() // create the forked repo forkedRepo, err := repo_service.ForkRepositoryAndUpdates(git.DefaultContext, baseUser, headUser, repo_service.ForkRepoOptions{ BaseRepo: baseRepo, Name: "forked-repo-pull-request", Description: "test pull-request event", }) require.NoError(t, err) require.NotEmpty(t, forkedRepo) return forkedRepo } forkedRepo := forkRepo(t, baseUser, baseRepo, headUser) addFileToForkedResp := actionsTrustTestRepoCreateBranch(t, headUser, forkedRepo) // create Pull pullIssue := &issues_model.Issue{ RepoID: baseRepo.ID, Title: "Test pull-request", PosterID: headUser.ID, Poster: headUser, IsPull: true, } pullRequest := &issues_model.PullRequest{ HeadRepoID: forkedRepo.ID, BaseRepoID: baseRepo.ID, HeadBranch: "fork-branch-1", BaseBranch: "main", HeadRepo: forkedRepo, BaseRepo: baseRepo, Type: issues_model.PullRequestGitea, } // create the pull request err := pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil) require.NoError(t, err) actionsTrustTestRequireRun(t, baseRepo, addFileToForkedResp) return forkedRepo, pullRequest, addFileToForkedResp } // Mark the PR as a work-in-progress PR func actionsTrustTestSetPullRequestWIP(t *testing.T, pullRequest *issues_model.PullRequest, wip bool) { t.Helper() newTitle := pullRequest.Issue.Title if wip && !pullRequest.IsWorkInProgress(t.Context()) { newTitle = fmt.Sprintf("WIP: %s", pullRequest.Issue.Title) } else if !wip { prefix := pullRequest.GetWorkInProgressPrefix(t.Context()) newTitle = pullRequest.Issue.Title[len(prefix):] } pullRequest.Issue.Title = newTitle require.NoError(t, issues_model.UpdateIssueCols(t.Context(), pullRequest.Issue, "name")) pullRequest.Issue = nil require.NoError(t, pullRequest.LoadIssue(t.Context())) } func TestActionsPullRequestTrustPanel(t *testing.T) { onApplicationRun(t, func(t *testing.T, u *url.URL) { ownerUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo regularUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) // a regular user with no specific permission regularSession := loginUser(t, regularUser.Name) userAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) // the instance admin adminSession := loginUser(t, userAdmin.Name) baseRepo, f := actionsTrustTestCreateBaseRepo(t, ownerUser) defer f() forkedRepo, pullRequest, addFileToForkedResp := actionsTrustTestCreatePullRequestFromForkedRepo(t, ownerUser, baseRepo, regularUser) pullRequestLink := pullRequest.Issue.Link() t.Run("Regular user sees a pending approval on a newly created pull request from a fork", func(t *testing.T) { actionsTrustTestAssertTrustPanel(t, regularSession, pullRequestLink) }) t.Run("Admin approves runs once", func(t *testing.T) { actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: addFileToForkedResp.Commit.SHA}) { actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: addFileToForkedResp.Commit.SHA}) assert.True(t, actionRun.NeedApproval) actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID}) assert.Equal(t, actions_model.StatusBlocked.String(), actionRunJob.Status.String()) } actionsTrustTestAssertTrustPanel(t, adminSession, pullRequestLink) actionsTrustTestClickTrustPanel(t, adminSession, pullRequestLink, string(actions_service.UserTrustedOnce)) { actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID}) assert.Equal(t, actions_model.StatusWaiting.String(), actionRunJob.Status.String()) actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID}) assert.False(t, actionRun.NeedApproval) } }) t.Run("All users sees no pending approval because it was approved once", func(t *testing.T) { actionsTrustTestAssertNoTrustPanel(t, regularSession, pullRequestLink) actionsTrustTestAssertNoTrustPanel(t, adminSession, pullRequestLink) }) modifiedForkedResp := actionsTrustTestRepoModify(t, regularUser, baseRepo, forkedRepo, "add_file_one.txt") t.Run("Regular user sees a pending approval on a modified pull request from a fork (2)", func(t *testing.T) { actionsTrustTestAssertTrustPanel(t, regularSession, pullRequestLink) }) t.Run("Admin denies runs", func(t *testing.T) { actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: modifiedForkedResp.Commit.SHA}) { actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: modifiedForkedResp.Commit.SHA}) assert.True(t, actionRun.NeedApproval) actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID}) assert.Equal(t, actions_model.StatusBlocked.String(), actionRunJob.Status.String()) } actionsTrustTestAssertTrustPanel(t, adminSession, pullRequestLink) actionsTrustTestClickTrustPanel(t, adminSession, pullRequestLink, string(actions_service.UserTrustDenied)) { actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID}) assert.Equal(t, actions_model.StatusCancelled.String(), actionRunJob.Status.String()) actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID}) assert.False(t, actionRun.NeedApproval) } }) t.Run("All users sees no pending approval because it was denied", func(t *testing.T) { actionsTrustTestAssertNoTrustPanel(t, regularSession, pullRequestLink) actionsTrustTestAssertNoTrustPanel(t, adminSession, pullRequestLink) }) modifiedForkedResp = actionsTrustTestRepoModify(t, regularUser, baseRepo, forkedRepo, "add_file_two.txt") t.Run("Regular user sees a pending approval on a modified pull request from a fork (2)", func(t *testing.T) { actionsTrustTestAssertTrustPanel(t, regularSession, pullRequestLink) }) t.Run("Admin always trusts the poster", func(t *testing.T) { actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: modifiedForkedResp.Commit.SHA}) { actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: modifiedForkedResp.Commit.SHA}) assert.True(t, actionRun.NeedApproval) actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID}) assert.Equal(t, actions_model.StatusBlocked.String(), actionRunJob.Status.String()) } actionsTrustTestAssertTrustPanel(t, adminSession, pullRequestLink) actionsTrustTestClickTrustPanel(t, adminSession, pullRequestLink, string(actions_service.UserAlwaysTrusted)) { actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID}) assert.Equal(t, actions_model.StatusWaiting.String(), actionRunJob.Status.String()) actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID}) assert.False(t, actionRun.NeedApproval) } }) t.Run("Regular users sees no pending approval because it was approved", func(t *testing.T) { actionsTrustTestAssertNoTrustPanel(t, regularSession, pullRequestLink) }) modifiedForkedResp = actionsTrustTestRepoModify(t, regularUser, baseRepo, forkedRepo, "add_file_three.txt") t.Run("No need for approval because the poster is always trusted", func(t *testing.T) { actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: modifiedForkedResp.Commit.SHA}) assert.False(t, actionRun.NeedApproval) actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID}) assert.Equal(t, actions_model.StatusWaiting.String(), actionRunJob.Status.String()) }) t.Run("Admin revokes the trusted poster", func(t *testing.T) { actionsTrustTestAssertTrustPanel(t, adminSession, pullRequestLink) actionsTrustTestClickTrustPanel(t, adminSession, pullRequestLink, string(actions_service.UserTrustRevoked)) }) modifiedForkedResp = actionsTrustTestRepoModify(t, regularUser, baseRepo, forkedRepo, "add_file_four.txt") t.Run("There needs to be an approval again because the user is no longer trusted", func(t *testing.T) { actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: modifiedForkedResp.Commit.SHA}) assert.True(t, actionRun.NeedApproval) actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID}) assert.Equal(t, actions_model.StatusBlocked.String(), actionRunJob.Status.String()) }) }) } func TestActionsPullRequestTrustPanelWIPConflicts(t *testing.T) { onApplicationRun(t, func(t *testing.T, u *url.URL) { ownerUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo regularUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) // a regular user with no specific permission regularSession := loginUser(t, regularUser.Name) userAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) // the instance admin adminSession := loginUser(t, userAdmin.Name) baseRepo, f := actionsTrustTestCreateBaseRepo(t, ownerUser) defer f() _, pullRequest, _ := actionsTrustTestCreatePullRequestFromForkedRepo(t, ownerUser, baseRepo, regularUser) pullRequestLink := pullRequest.Issue.Link() actionsTrustTestSetPullRequestWIP(t, pullRequest, true) actionsTrustTestAssertPRIsWIP(t, adminSession, pullRequestLink) t.Run("Regular user sees pending approval even though PR is a WIP PR", func(t *testing.T) { actionsTrustTestAssertTrustPanel(t, regularSession, pullRequestLink) }) actionsTrustTestSetPullRequestWIP(t, pullRequest, false) _ = actionsTrustMakePRConflicted(t, userAdmin, baseRepo) actionsTrustTestAssertPRConflicted(t, adminSession, pullRequestLink) t.Run("Regular user sees pending approval even though PR is conflicted", func(t *testing.T) { actionsTrustTestAssertTrustPanel(t, regularSession, pullRequestLink) }) }) } func TestActionsPullRequestTrustCancelOnClose(t *testing.T) { onApplicationRun(t, func(t *testing.T, u *url.URL) { ownerUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) regularUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) regularSession := loginUser(t, regularUser.Name) token := getTokenForLoggedInUser(t, regularSession, auth_model.AccessTokenScopeWriteIssue) baseRepo, f := actionsTrustTestCreateBaseRepo(t, ownerUser) defer f() _, pullRequest, addFileToForkedResp := actionsTrustTestCreatePullRequestFromForkedRepo(t, ownerUser, baseRepo, regularUser) prAPILink := pullRequest.Issue.APIURL(t.Context()) { actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: addFileToForkedResp.Commit.SHA}) assert.True(t, actionRun.NeedApproval) actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID}) assert.Equal(t, actions_model.StatusBlocked, actionRunJob.Status) } req := NewRequestWithJSON(t, "PATCH", prAPILink, &structs.PullRequest{State: "closed"}).AddTokenAuth(token) MakeRequest(t, req, http.StatusCreated) { actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: addFileToForkedResp.Commit.SHA}) assert.False(t, actionRun.NeedApproval) assert.Equal(t, actions_model.StatusCancelled, actionRun.Status) actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID}) assert.Equal(t, actions_model.StatusCancelled, actionRunJob.Status) } }) }