diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index a707e564a7..edca1072a8 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -56,6 +56,19 @@ "relativetime.2months": "two months ago", "relativetime.1year": "last year", "relativetime.2years": "two years ago", + "repo.pulls.poster_manage_approval": "Manage approval", + "repo.pulls.poster_requires_approval": "Some workflows are waiting to be reviewed.", + "repo.pulls.poster_requires_approval.tooltip": "The author of this pull request is not trusted to run workflows triggered by a pull request created from a forked repository or with AGit. The workflows triggered by a `pull_request` event will not run until they are approved.", + "repo.pulls.poster_is_trusted": "The author of this pull request is always trusted to run workflows.", + "repo.pulls.poster_is_trusted.tooltip": "The author of this pull request is explicitly trusted to run workflows triggered by `pull_request` events.", + "repo.pulls.poster_trust_deny": "Deny", + "repo.pulls.poster_trust_deny.tooltip": "The workflows waiting approval will be canceled.", + "repo.pulls.poster_trust_once": "Approve once", + "repo.pulls.poster_trust_once.tooltip": "The workflows triggered by a `pull_request` event will run on this commit but will need to be approved for all future commits pushed to this pull request.", + "repo.pulls.poster_trust_always": "Approve always", + "repo.pulls.poster_trust_always.tooltip": "The workflows triggered by a `pull_request` event will run on this commit and there will be no need to approve runs from this pull request or future pull requests authored by the same user.", + "repo.pulls.poster_trust_revoke": "Revoke", + "repo.pulls.poster_trust_revoke.tooltip": "The author of this pull request will no longer be trusted to run workflows triggered by a `pull_request` event, each run will have to be manually approved.", "repo.pulls.already_merged": "Merge failed: This pull request has already been merged.", "repo.pulls.merged_title_desc": { "one": "merged %[1]d commit from %[2]s into %[3]s %[4]s", @@ -137,6 +150,7 @@ "admin.auths.allow_username_change.description": "Allow users to change their username in the profile settings", "admin.dashboard.cleanup_offline_runners": "Cleanup offline runners", "admin.dashboard.remove_resolved_reports": "Remove resolved reports", + "admin.dashboard.actions_action_user": "Revoke Forgejo Actions trust for inactive users", "admin.config.security": "Security configuration", "admin.config.global_2fa_requirement.title": "Global two-factor requirement", "admin.config.global_2fa_requirement.none": "No", diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index dee630d96b..453637e7d8 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -18,6 +18,7 @@ import ( "strings" "forgejo.org/models" + actions_model "forgejo.org/models/actions" activities_model "forgejo.org/models/activities" asymkey_model "forgejo.org/models/asymkey" "forgejo.org/models/db" @@ -44,6 +45,7 @@ import ( "forgejo.org/modules/util" "forgejo.org/modules/web" "forgejo.org/routers/utils" + actions_service "forgejo.org/services/actions" asymkey_service "forgejo.org/services/asymkey" "forgejo.org/services/automerge" "forgejo.org/services/context" @@ -764,6 +766,11 @@ func PrepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C ctx.Data["IsNothingToCompare"] = true } + PrepareViewPullInfoActions(ctx, pull) + if ctx.Written() { + return nil + } + if pull.IsWorkInProgress(ctx) { ctx.Data["IsPullWorkInProgress"] = true ctx.Data["WorkInProgressPrefix"] = pull.GetWorkInProgressPrefix(ctx) @@ -1943,3 +1950,67 @@ func SetAllowEdits(ctx *context.Context) { "allow_maintainer_edit": pr.AllowMaintainerEdit, }) } + +func PrepareViewPullInfoActions(ctx *context.Context, pull *issues_model.PullRequest) { + canReadUnitActions := ctx.Repo.CanRead(unit.TypeActions) + ctx.Data["CanReadUnitActions"] = canReadUnitActions + + if !canReadUnitActions { + return + } + + PrepareViewPullInfoActionsTrust(ctx, pull) + if ctx.Written() { + return + } +} + +func PrepareViewPullInfoActionsTrust(ctx *context.Context, pull *issues_model.PullRequest) { + trusted, err := actions_service.GetPullRequestPosterIsTrustedWithActions(ctx, pull) + if err != nil { + ctx.ServerError("GetPullRequestUserIsTrustedWithActions", err) + return + } + ctx.Data["PullRequestPosterIsNotTrustedWithActions"] = trusted == actions_service.UserIsNotTrustedWithActions + ctx.Data["PullRequestPosterIsExplicitlyTrustedWithActions"] = trusted == actions_service.UserIsExplicitlyTrustedWithActions + ctx.Data["PullRequestPosterIsImplicitlyTrustedWithActions"] = trusted == actions_service.UserIsImplicitlyTrustedWithActions + + someRunsNeedApproval, err := actions_model.HasRunThatNeedApproval(ctx, pull.Issue.RepoID, pull.ID) + if err != nil { + ctx.ServerError("HasRunThatNeedApproval", err) + } + ctx.Data["SomePullRequestRunsNeedApproval"] = someRunsNeedApproval + + ctx.Data["UserCanDelegateTrustWithPullRequest"] = context.CheckRepoDelegateActionTrust(ctx) +} + +func UpdateTrustWithPullRequestActions(ctx *context.Context) { + pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound("GetPullRequestByIndex", err) + } else { + ctx.ServerError("GetPullRequestByIndex", err) + } + return + } + + trust := ctx.FormString("trust") + + if err := actions_service.UpdateTrustedWithPullRequest(ctx, ctx.Doer.ID, pr, actions_service.TrustUpdate(trust)); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + if err := pr.LoadIssue(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + if err := pr.Issue.LoadRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + ctx.Redirect(fmt.Sprintf("%s#pull-request-trust-panel", pr.Issue.Link())) +} diff --git a/routers/web/web.go b/routers/web/web.go index fb0306e6d2..4e064ed40a 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -846,6 +846,7 @@ func registerRoutes(m *web.Route) { reqRepoProjectsWriter := context.RequireRepoWriter(unit.TypeProjects) reqRepoActionsReader := context.RequireRepoReader(unit.TypeActions) reqRepoActionsWriter := context.RequireRepoWriter(unit.TypeActions) + reqRepoDelegateActionTrust := context.RequireRepoDelegateActionTrust() reqPackageAccess := func(accessMode perm.AccessMode) func(ctx *context.Context) { return func(ctx *context.Context) { @@ -1215,6 +1216,7 @@ func registerRoutes(m *web.Route) { m.Group("/{type:issues|pulls}", func() { m.Group("/{index}", func() { m.Post("/title", repo.UpdateIssueTitle) + m.Post("/action-user-trust", reqRepoActionsReader, actions.MustEnableActions, reqRepoDelegateActionTrust, repo.UpdateTrustWithPullRequestActions) m.Post("/content", repo.UpdateIssueContent) m.Post("/deadline", web.Bind(structs.EditDeadlineOption{}), repo.UpdateIssueDeadline) m.Post("/watch", repo.IssueWatch) diff --git a/services/context/permission.go b/services/context/permission.go index b6af87f912..483758141c 100644 --- a/services/context/permission.go +++ b/services/context/permission.go @@ -111,6 +111,19 @@ func RequireRepoReaderOr(unitTypes ...unit.Type) func(ctx *Context) { } } +func RequireRepoDelegateActionTrust() func(ctx *Context) { + return func(ctx *Context) { + if CheckRepoDelegateActionTrust(ctx) { + return + } + ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + } +} + +func CheckRepoDelegateActionTrust(ctx *Context) bool { + return ctx.Repo.IsAdmin() || (ctx.IsSigned && ctx.Doer.IsAdmin) || ctx.Repo.CanWrite(unit.TypeActions) +} + // CheckRepoScopedToken check whether personal access token has repo scope func CheckRepoScopedToken(ctx *Context, repo *repo_model.Repository, level auth_model.AccessTokenScopeLevel) { if !ctx.IsBasicAuth || ctx.Data["IsApiToken"] != true { diff --git a/templates/repo/actions/view.tmpl b/templates/repo/actions/view.tmpl index 5b8b44639e..6adc91417c 100644 --- a/templates/repo/actions/view.tmpl +++ b/templates/repo/actions/view.tmpl @@ -12,7 +12,7 @@ data-workflow-url="{{.WorkflowURL}}" data-initial-post-response="{{.InitialData}}" data-initial-artifacts-response="{{.InitialArtifactsData}}" - data-locale-approve="{{ctx.Locale.Tr "repo.diff.review.approve"}}" + data-locale-approve="{{ctx.Locale.Tr "repo.pulls.poster_manage_approval"}}" data-locale-cancel="{{ctx.Locale.Tr "cancel"}}" data-locale-rerun="{{ctx.Locale.Tr "rerun"}}" data-locale-rerun-all="{{ctx.Locale.Tr "rerun_all"}}" diff --git a/templates/repo/issue/view_content/pull.tmpl b/templates/repo/issue/view_content/pull.tmpl index 101474d0ff..21f0d613ed 100644 --- a/templates/repo/issue/view_content/pull.tmpl +++ b/templates/repo/issue/view_content/pull.tmpl @@ -94,6 +94,7 @@ {{end}} + {{template "repo/pulls/trust" .}} {{template "repo/issue/view_content/update_branch_by_merge" $}} {{else if .Issue.PullRequest.IsChecking}}
@@ -187,6 +188,7 @@
{{end}} {{end}} + {{template "repo/pulls/trust" .}} {{template "repo/issue/view_content/update_branch_by_merge" $}} {{if .Issue.PullRequest.IsEmpty}}
diff --git a/templates/repo/pulls/trust.tmpl b/templates/repo/pulls/trust.tmpl new file mode 100644 index 0000000000..f715f27bbf --- /dev/null +++ b/templates/repo/pulls/trust.tmpl @@ -0,0 +1,61 @@ +{{/* +Template Attributes: +* CanReadUnitActions: true if the actions unit is active and readable +* SomePullRequestRunsNeedApproval: true if there is at least one run waiting for approval +* UserCanDelegateTrustWithPullRequest: true if the user can delegate trust in the context of pull requests +* PullRequestPosterIsNotTrustedWithActions: true if the poster of the pull request needs to be approved to run actions +* PullRequestPosterIsExplicitlyTrustedWithActions: true if the poster of the pull request is trusted to run actions (once or always) +* PullRequestPosterIsImplicitlyTrustedWithActions: true if the poster of the pull request is trusted to run actions because of elevated permissions +* Link: URL to the pull request +*/}} + +{{if .CanReadUnitActions}} + {{if and .UserCanDelegateTrustWithPullRequest .PullRequestPosterIsExplicitlyTrustedWithActions}} +
+
+
+
+ {{ctx.Locale.Tr "repo.pulls.poster_is_trusted" "https://forgejo.org/docs/latest/user/actions/security-pull-request/"}} +
+
+
+ + +
+
+
+
+ {{else if and .PullRequestPosterIsNotTrustedWithActions .SomePullRequestRunsNeedApproval}} +
+
+
+
+ {{svg "octicon-alert" 16 "text red"}} + {{ctx.Locale.Tr "repo.pulls.poster_requires_approval" "https://forgejo.org/docs/latest/user/actions/security-pull-request/"}} +
+ {{if .UserCanDelegateTrustWithPullRequest}} +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ {{end}} +
+
+ {{end}} +{{end}} diff --git a/tests/integration/actions_trust_test.go b/tests/integration/actions_trust_test.go new file mode 100644 index 0000000000..7a683d5625 --- /dev/null +++ b/tests/integration/actions_trust_test.go @@ -0,0 +1,355 @@ +// 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" + 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" + 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 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") +} + +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") + // 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 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 +} + +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()) + }) + }) +} diff --git a/web_src/js/components/RepoActionView.test.js b/web_src/js/components/RepoActionView.test.js index 6f575ca8da..b06751b8cf 100644 --- a/web_src/js/components/RepoActionView.test.js +++ b/web_src/js/components/RepoActionView.test.js @@ -397,6 +397,44 @@ test('historical attempt dropdown interactions', async () => { expect(window.location.href).toEqual(toAbsoluteUrl('/user1/repo2/actions/runs/123/jobs/1/attempt/2')); }); +test('run approval interaction', async () => { + const pullRequestLink = '/example-org/example-repo/pulls/456'; + const wrapper = mount(RepoActionView, { + props: { + ...defaultTestProps, + initialJobData: { + state: { + run: { + canApprove: true, + status: 'waiting', + commit: { + pusher: {}, + branch: { + link: toAbsoluteUrl(pullRequestLink), + }, + }, + }, + currentJob: { + steps: [ + { + summary: 'Test Job', + }, + ], + }, + }, + logs: { + stepsLog: [], + }, + }, + }, + }); + await flushPromises(); + const approve = wrapper.findAll('button').filter((button) => button.text() === 'Locale Approve'); + expect(approve.length).toEqual(1); + approve[0].trigger('click'); + expect(window.location.href).toEqual(toAbsoluteUrl(`${pullRequestLink}#pull-request-trust-panel`)); +}); + test('artifacts download links', async () => { Object.defineProperty(document.documentElement, 'lang', {value: 'en'}); vi.spyOn(global, 'fetch').mockImplementation((url, opts) => { diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 097676cac2..9b2ba0166c 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -223,7 +223,8 @@ export default { }, // approve a run approveRun() { - POST(`${this.run.link}/approve`); + const url = `${this.run.commit.branch.link}#pull-request-trust-panel`; + window.location.href = url; }, // show/hide the step logs for a group toggleGroupLogs(event) {