mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-04 16:37:42 +00:00
a65f422b89
Implements the missing REST API endpoints for Actions workflow run
management:
1. `POST /actions/runs/{run}/cancel` cancels a run and its jobs, `409`
when it already finished
1. `POST /actions/runs/{run}/approve` approves a run awaiting approval,
idempotent, `409` when it never awaited one
1. `GET /actions/runs/{run}/logs` downloads the latest attempt's job
logs as a zip archive
`ActionWorkflowRun` gains `created_at`, `updated_at` and the `jobs_url`,
`logs_url`, `artifacts_url`, `cancel_url` and `rerun_url` fields, and
now always emits `conclusion` and `head_branch`.
Cancellation is shared with the web handler in `services/actions`.
Fixes https://github.com/go-gitea/gitea/issues/35176
Fixes https://github.com/go-gitea/gitea/issues/36554
---------
Co-authored-by: Claude Sonnet 4.6 <claude-sonnet-4-6@anthropic.com>
Co-authored-by: OpenCode Agent <opencode@rossgolder.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
640 lines
27 KiB
Go
640 lines
27 KiB
Go
// Copyright 2025 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package integration
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
|
|
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
|
actions_model "gitea.dev/models/actions"
|
|
auth_model "gitea.dev/models/auth"
|
|
"gitea.dev/models/db"
|
|
repo_model "gitea.dev/models/repo"
|
|
"gitea.dev/models/unittest"
|
|
user_model "gitea.dev/models/user"
|
|
"gitea.dev/modules/actions"
|
|
api "gitea.dev/modules/structs"
|
|
"gitea.dev/modules/timeutil"
|
|
"gitea.dev/tests"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
)
|
|
|
|
func TestAPIActionsWorkflowRun(t *testing.T) {
|
|
defer prepareTestEnvActionsArtifacts(t)()
|
|
t.Run("GetWorkflowRun", testAPIActionsGetWorkflowRun)
|
|
t.Run("GetWorkflowJob", testAPIActionsGetWorkflowJob)
|
|
t.Run("ListUserWorkflows", testAPIActionsListUserWorkflows)
|
|
t.Run("ListRepoWorkflows", testAPIActionsListRepoWorkflows)
|
|
t.Run("DeleteRunCheckPermission", testAPIActionsDeleteRunCheckPermission)
|
|
t.Run("DeleteRunRunning", testAPIActionsDeleteRunRunning)
|
|
t.Run("GetWorkflowRunLogsNotFound", testAPIActionsGetWorkflowRunLogsNotFound)
|
|
t.Run("GetWorkflowJobLogsNotFound", testAPIActionsGetWorkflowJobLogsNotFound)
|
|
// finishes run 793, so it must come after everything that needs it still running
|
|
t.Run("CancelWorkflowRun", testAPIActionsCancelWorkflowRun)
|
|
t.Run("ApproveWorkflowRun", testAPIActionsApproveWorkflowRun)
|
|
// deletes run 795, so it must come after everything that reads it
|
|
t.Run("DeleteRunGeneral", testAPIActionsDeleteRunGeneral)
|
|
}
|
|
|
|
func testAPIActionsGetWorkflowRun(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
session := loginUser(t, user.Name)
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
|
|
|
t.Run("GetRun", func(t *testing.T) {
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/802802", repo.FullName())).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/802", repo.FullName())).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/803", repo.FullName())).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusOK)
|
|
})
|
|
|
|
t.Run("GetJobSteps", func(t *testing.T) {
|
|
// Insert task steps for task_id 53 (job 198) so the API can return them once the backend loads them
|
|
_, err := db.GetEngine(t.Context()).Insert(&actions_model.ActionTaskStep{
|
|
Name: "main",
|
|
TaskID: 53,
|
|
Index: 0,
|
|
RepoID: repo.ID,
|
|
Status: actions_model.StatusSuccess,
|
|
Started: timeutil.TimeStamp(1683636528),
|
|
Stopped: timeutil.TimeStamp(1683636626),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs", repo.FullName())).
|
|
AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
|
|
jobList := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{})
|
|
|
|
job198Idx := slices.IndexFunc(jobList.Entries, func(job *api.ActionWorkflowJob) bool { return job.ID == 198 })
|
|
require.NotEqual(t, -1, job198Idx, "expected to find job 198 in run 795 jobs list")
|
|
job198 := jobList.Entries[job198Idx]
|
|
require.NotEmpty(t, job198.Steps, "job must return at least one step when task has steps")
|
|
assert.Equal(t, "main", job198.Steps[0].Name, "first step name")
|
|
})
|
|
}
|
|
|
|
func testAPIActionsGetWorkflowJob(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
session := loginUser(t, user.Name)
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
|
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/198198", repo.FullName())).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/198", repo.FullName())).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusOK)
|
|
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/196", repo.FullName())).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIActionsDeleteRunCheckPermission(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
session := loginUser(t, user.Name)
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
|
testAPIActionsDeleteRun(t, repo, token, http.StatusNotFound)
|
|
}
|
|
|
|
func testAPIActionsDeleteRunGeneral(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
session := loginUser(t, user.Name)
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
|
|
|
testAPIActionsDeleteRunListArtifacts(t, repo, token, 2)
|
|
testAPIActionsDeleteRunListTasks(t, repo, token, true)
|
|
testAPIActionsDeleteRun(t, repo, token, http.StatusNoContent)
|
|
|
|
testAPIActionsDeleteRunListArtifacts(t, repo, token, 0)
|
|
testAPIActionsDeleteRunListTasks(t, repo, token, false)
|
|
testAPIActionsDeleteRun(t, repo, token, http.StatusNotFound)
|
|
}
|
|
|
|
// needs run 793 still running, so it must come before CancelWorkflowRun
|
|
func testAPIActionsDeleteRunRunning(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
session := loginUser(t, user.Name)
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
|
|
|
req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793", repo.FullName())).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusBadRequest)
|
|
}
|
|
|
|
func testAPIActionsDeleteRun(t *testing.T, repo *repo_model.Repository, token string, expected int) {
|
|
req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795", repo.FullName())).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, expected)
|
|
}
|
|
|
|
func testAPIActionsDeleteRunListArtifacts(t *testing.T, repo *repo_model.Repository, token string, artifacts int) {
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/artifacts", repo.FullName())).
|
|
AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
listResp := DecodeJSON(t, resp, &api.ActionArtifactsResponse{})
|
|
assert.Len(t, listResp.Entries, artifacts)
|
|
}
|
|
|
|
func testAPIActionsDeleteRunListTasks(t *testing.T, repo *repo_model.Repository, token string, expected bool) {
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/tasks", repo.FullName())).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
listResp := DecodeJSON(t, resp, &api.ActionTaskResponse{})
|
|
findTask1 := false
|
|
findTask2 := false
|
|
for _, entry := range listResp.Entries {
|
|
if entry.ID == 53 {
|
|
findTask1 = true
|
|
continue
|
|
}
|
|
if entry.ID == 54 {
|
|
findTask2 = true
|
|
continue
|
|
}
|
|
}
|
|
assert.Equal(t, expected, findTask1)
|
|
assert.Equal(t, expected, findTask2)
|
|
}
|
|
|
|
// TestAPIActionsRerunWorkflowRun covers everything that mutates run 795, in a fixed order so
|
|
// they can share one fixture load: the log download has to see the original tasks, the job
|
|
// rerun needs the run still done, and the full rerun re-arms it by cancelling first.
|
|
func TestAPIActionsRerunWorkflowRun(t *testing.T) {
|
|
defer prepareTestEnvActionsArtifacts(t)()
|
|
|
|
t.Run("NotDone", func(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
session := loginUser(t, user.Name)
|
|
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
|
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/rerun", repo.FullName())).
|
|
AddTokenAuth(writeToken)
|
|
MakeRequest(t, req, http.StatusBadRequest)
|
|
|
|
req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/jobs/194/rerun", repo.FullName())).
|
|
AddTokenAuth(writeToken)
|
|
MakeRequest(t, req, http.StatusBadRequest)
|
|
})
|
|
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
session := loginUser(t, user.Name)
|
|
|
|
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
|
readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
|
|
|
|
t.Run("RunLogs", func(t *testing.T) {
|
|
// run 795 (workflow "test.yaml") has job 198 "job_1" on task 53 and job 199 "job_2" on task 54
|
|
seedTaskLogs(t, 53, "hello from job_1")
|
|
seedTaskLogs(t, 54, "hello from job_2")
|
|
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName())).
|
|
AddTokenAuth(writeToken)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
|
|
assert.Equal(t, "application/zip", resp.Header().Get("Content-Type"))
|
|
assert.Contains(t, resp.Header().Get("Content-Disposition"), "test-run-795-logs.zip")
|
|
assert.Equal(t, "Content-Disposition", resp.Header().Get("Access-Control-Expose-Headers"))
|
|
|
|
body := resp.Body.Bytes()
|
|
archive, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
|
require.NoError(t, err)
|
|
|
|
contents := make(map[string]string, len(archive.File))
|
|
for _, file := range archive.File {
|
|
r, err := file.Open()
|
|
require.NoError(t, err)
|
|
content, err := io.ReadAll(r)
|
|
require.NoError(t, r.Close())
|
|
require.NoError(t, err)
|
|
contents[file.Name] = string(content)
|
|
}
|
|
|
|
require.Len(t, contents, 2)
|
|
assert.Contains(t, contents["test-job_1-53.log"], "hello from job_1")
|
|
assert.Contains(t, contents["test-job_2-54.log"], "hello from job_2")
|
|
})
|
|
|
|
t.Run("JobSuccess", func(t *testing.T) {
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/199/rerun", repo.FullName())).
|
|
AddTokenAuth(writeToken)
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
|
|
|
rerunResp := DecodeJSON(t, resp, &api.ActionWorkflowJob{})
|
|
job199Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 199)
|
|
assert.Equal(t, job199Rerun.ID, rerunResp.ID)
|
|
assert.Equal(t, "queued", rerunResp.Status)
|
|
|
|
run, err := actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, actions_model.StatusWaiting, run.Status)
|
|
latestAttempt, hasLatestAttempt, err := run.GetLatestAttempt(t.Context())
|
|
require.NoError(t, err)
|
|
require.True(t, hasLatestAttempt)
|
|
|
|
job198Rerun := getLatestAttemptJobByTemplateJobID(t, 795, 198)
|
|
assert.Equal(t, actions_model.StatusSuccess, job198Rerun.Status)
|
|
assert.Equal(t, latestAttempt.Attempt, job198Rerun.Attempt)
|
|
assert.Equal(t, int64(0), job198Rerun.TaskID)
|
|
assert.Equal(t, int64(53), job198Rerun.SourceTaskID)
|
|
|
|
job199Rerun = getLatestAttemptJobByTemplateJobID(t, 795, 199)
|
|
assert.Equal(t, actions_model.StatusWaiting, job199Rerun.Status)
|
|
assert.Equal(t, latestAttempt.Attempt, job199Rerun.Attempt)
|
|
assert.Equal(t, int64(0), job199Rerun.TaskID)
|
|
assert.Equal(t, int64(0), job199Rerun.SourceTaskID)
|
|
})
|
|
|
|
t.Run("Success", func(t *testing.T) {
|
|
// JobSuccess above leaves the run waiting, so finish it to make it rerunnable again.
|
|
// Run on its own the fixture run is still done and needs no cancelling.
|
|
run, err := actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795)
|
|
require.NoError(t, err)
|
|
if !run.Status.IsDone() {
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/cancel", repo.FullName())).
|
|
AddTokenAuth(writeToken)
|
|
MakeRequest(t, req, http.StatusOK)
|
|
}
|
|
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/rerun", repo.FullName())).
|
|
AddTokenAuth(writeToken)
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
|
|
|
rerunResp := DecodeJSON(t, resp, &api.ActionWorkflowRun{})
|
|
assert.Equal(t, int64(795), rerunResp.ID)
|
|
assert.Equal(t, "queued", rerunResp.Status)
|
|
assert.Equal(t, "c2d72f548424103f01ee1dc02889c1e2bff816b0", rerunResp.HeadSha)
|
|
|
|
run, err = actions_model.GetRunByRepoAndID(t.Context(), repo.ID, 795)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, actions_model.StatusWaiting, run.Status)
|
|
assert.Equal(t, timeutil.TimeStamp(0), run.Started)
|
|
assert.Equal(t, timeutil.TimeStamp(0), run.Stopped)
|
|
latestAttempt, hasLatestAttempt, err := run.GetLatestAttempt(t.Context())
|
|
require.NoError(t, err)
|
|
require.True(t, hasLatestAttempt)
|
|
|
|
job198 := getLatestAttemptJobByTemplateJobID(t, 795, 198)
|
|
assert.Equal(t, actions_model.StatusWaiting, job198.Status)
|
|
assert.Equal(t, latestAttempt.Attempt, job198.Attempt)
|
|
assert.Equal(t, int64(0), job198.TaskID)
|
|
|
|
job199 := getLatestAttemptJobByTemplateJobID(t, 795, 199)
|
|
assert.Equal(t, actions_model.StatusWaiting, job199.Status)
|
|
assert.Equal(t, latestAttempt.Attempt, job199.Attempt)
|
|
assert.Equal(t, int64(0), job199.TaskID)
|
|
})
|
|
|
|
t.Run("ForbiddenWithoutWriteScope", func(t *testing.T) {
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/rerun", repo.FullName())).
|
|
AddTokenAuth(readToken)
|
|
MakeRequest(t, req, http.StatusForbidden)
|
|
})
|
|
|
|
t.Run("NotFound", func(t *testing.T) {
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/rerun", repo.FullName())).
|
|
AddTokenAuth(writeToken)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("NotFoundJob", func(t *testing.T) {
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/999999/rerun", repo.FullName())).
|
|
AddTokenAuth(writeToken)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("NoLogsAfterRerun", func(t *testing.T) {
|
|
// the full rerun above cleared both TaskID and SourceTaskID on every latest-attempt job
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName())).
|
|
AddTokenAuth(writeToken)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
})
|
|
}
|
|
|
|
func testAPIActionsCancelWorkflowRun(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
ownerSession := loginUser(t, owner.Name)
|
|
ownerToken := getTokenForLoggedInUser(t, ownerSession, auth_model.AccessTokenScopeWriteRepository)
|
|
|
|
t.Run("Success", func(t *testing.T) {
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/cancel", repo.FullName())).
|
|
AddTokenAuth(ownerToken)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
cancelledRun := DecodeJSON(t, resp, &api.ActionWorkflowRun{})
|
|
assert.Equal(t, int64(793), cancelledRun.ID)
|
|
assert.Equal(t, "completed", cancelledRun.Status)
|
|
assert.Equal(t, "cancelled", cancelledRun.Conclusion)
|
|
})
|
|
|
|
t.Run("AlreadyCompleted", func(t *testing.T) {
|
|
// run 791 already succeeded, so there is nothing left to cancel
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/791/cancel", repo.FullName())).
|
|
AddTokenAuth(ownerToken)
|
|
MakeRequest(t, req, http.StatusConflict)
|
|
})
|
|
|
|
t.Run("NotFound", func(t *testing.T) {
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/cancel", repo.FullName())).
|
|
AddTokenAuth(ownerToken)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("ForbiddenWithoutPermission", func(t *testing.T) {
|
|
// user2 is not the owner of repo4 (owned by user5)
|
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
user2Session := loginUser(t, user2.Name)
|
|
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository)
|
|
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/793/cancel", repo.FullName())).
|
|
AddTokenAuth(user2Token)
|
|
MakeRequest(t, req, http.StatusForbidden)
|
|
})
|
|
}
|
|
|
|
func testAPIActionsApproveWorkflowRun(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
|
// user5 owns repo4, user4 is a write collaborator on it, user2 has no access at all
|
|
ownerToken := getTokenForLoggedInUser(t, loginUser(t, "user5"), auth_model.AccessTokenScopeWriteRepository)
|
|
writerToken := getTokenForLoggedInUser(t, loginUser(t, "user4"), auth_model.AccessTokenScopeWriteRepository)
|
|
strangerToken := getTokenForLoggedInUser(t, loginUser(t, "user2"), auth_model.AccessTokenScopeWriteRepository)
|
|
|
|
// a fork PR from a first-time contributor is what produces these in practice, which
|
|
// actions_approve_test.go already covers end to end
|
|
insertBlockedRun := func(index int64) *actions_model.ActionRun {
|
|
run := &actions_model.ActionRun{
|
|
Title: "needs approval", RepoID: repo.ID, OwnerID: repo.OwnerID, WorkflowID: "test.yaml", Index: index,
|
|
TriggerUserID: 4, Ref: "refs/heads/main", CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
|
Event: "pull_request", TriggerEvent: "pull_request",
|
|
Status: actions_model.StatusBlocked, NeedApproval: true,
|
|
}
|
|
require.NoError(t, db.Insert(t.Context(), run))
|
|
require.NoError(t, db.Insert(t.Context(), &actions_model.ActionRunJob{
|
|
RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA,
|
|
Name: "job1", Attempt: 1, JobID: "job1", Status: actions_model.StatusBlocked, RunsOn: []string{"ubuntu-latest"},
|
|
}))
|
|
return run
|
|
}
|
|
|
|
assertApproved := func(t *testing.T, runID, approverID int64) {
|
|
t.Helper()
|
|
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
|
|
assert.False(t, run.NeedApproval)
|
|
assert.Equal(t, approverID, run.ApprovedBy)
|
|
jobs, err := actions_model.GetLatestAttemptJobsByRun(t.Context(), run)
|
|
require.NoError(t, err)
|
|
for _, job := range jobs {
|
|
assert.Equal(t, actions_model.StatusWaiting, job.Status)
|
|
}
|
|
}
|
|
|
|
run := insertBlockedRun(2001)
|
|
|
|
t.Run("ForbiddenWithoutPermission", func(t *testing.T) {
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", repo.FullName(), run.ID)).
|
|
AddTokenAuth(strangerToken)
|
|
MakeRequest(t, req, http.StatusForbidden)
|
|
})
|
|
|
|
t.Run("AsOwner", func(t *testing.T) {
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", repo.FullName(), run.ID)).
|
|
AddTokenAuth(ownerToken)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
apiRun := DecodeJSON(t, resp, &api.ActionWorkflowRun{})
|
|
assert.Equal(t, run.ID, apiRun.ID)
|
|
assertApproved(t, run.ID, 5)
|
|
})
|
|
|
|
t.Run("AgainIsIdempotent", func(t *testing.T) {
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", repo.FullName(), run.ID)).
|
|
AddTokenAuth(ownerToken)
|
|
MakeRequest(t, req, http.StatusOK)
|
|
assertApproved(t, run.ID, 5)
|
|
})
|
|
|
|
t.Run("AsWriterNonAdmin", func(t *testing.T) {
|
|
writerRun := insertBlockedRun(2002)
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/%d/approve", repo.FullName(), writerRun.ID)).
|
|
AddTokenAuth(writerToken)
|
|
MakeRequest(t, req, http.StatusOK)
|
|
assertApproved(t, writerRun.ID, 4)
|
|
})
|
|
|
|
t.Run("NotRequired", func(t *testing.T) {
|
|
// run 791 succeeded without ever awaiting approval
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/791/approve", repo.FullName())).
|
|
AddTokenAuth(ownerToken)
|
|
MakeRequest(t, req, http.StatusConflict)
|
|
})
|
|
}
|
|
|
|
func testAPIActionsListUserWorkflows(t *testing.T) {
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
session := loginUser(t, user.Name)
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
|
|
|
|
t.Run("Runs", func(t *testing.T) {
|
|
req := NewRequest(t, "GET", "/api/v1/user/actions/runs").AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
runs := DecodeJSON(t, resp, &api.ActionWorkflowRunsResponse{})
|
|
|
|
assert.Positive(t, runs.TotalCount)
|
|
assert.NotEmpty(t, runs.Entries)
|
|
|
|
for _, run := range runs.Entries {
|
|
assert.NotEmpty(t, run.DisplayTitle, "display_title should be populated")
|
|
assert.NotNil(t, run.Repository, "repository should be populated via batch loading")
|
|
assert.NotEmpty(t, run.Repository.FullName, "repository full_name should be populated")
|
|
assert.NotNil(t, run.TriggerActor, "trigger_actor should be populated via batch loading")
|
|
}
|
|
})
|
|
|
|
t.Run("Jobs", func(t *testing.T) {
|
|
req := NewRequest(t, "GET", "/api/v1/user/actions/jobs").AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
jobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{})
|
|
|
|
assert.Positive(t, jobs.TotalCount)
|
|
assert.NotEmpty(t, jobs.Entries)
|
|
|
|
for _, job := range jobs.Entries {
|
|
assert.NotEmpty(t, job.Name, "job name should be populated")
|
|
assert.NotEmpty(t, job.HTMLURL, "html_url should be populated via batch-loaded repo")
|
|
}
|
|
})
|
|
|
|
t.Run("JobsDefaultOrderAsc", func(t *testing.T) {
|
|
req := NewRequest(t, "GET", "/api/v1/user/actions/jobs").AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
jobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{})
|
|
|
|
assert.GreaterOrEqual(t, len(jobs.Entries), 2, "need at least 2 jobs to verify ordering")
|
|
for i := 1; i < len(jobs.Entries); i++ {
|
|
assert.Less(t, jobs.Entries[i-1].ID, jobs.Entries[i].ID,
|
|
"jobs should be ordered by ID ascending by default")
|
|
}
|
|
})
|
|
|
|
t.Run("JobsOrderedByIDDesc", func(t *testing.T) {
|
|
req := NewRequest(t, "GET", "/api/v1/user/actions/jobs?sort=id&order=desc").AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
jobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{})
|
|
|
|
assert.GreaterOrEqual(t, len(jobs.Entries), 2, "need at least 2 jobs to verify ordering")
|
|
for i := 1; i < len(jobs.Entries); i++ {
|
|
assert.Greater(t, jobs.Entries[i-1].ID, jobs.Entries[i].ID,
|
|
"jobs should be ordered by ID descending")
|
|
}
|
|
})
|
|
}
|
|
|
|
func testAPIActionsListRepoWorkflows(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
session := loginUser(t, user.Name)
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
|
|
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs", repo.FullName())).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
runs := DecodeJSON(t, resp, &api.ActionWorkflowRunsResponse{})
|
|
|
|
assert.Positive(t, runs.TotalCount)
|
|
assert.NotEmpty(t, runs.Entries)
|
|
|
|
for _, run := range runs.Entries {
|
|
assert.NotNil(t, run.Repository, "repository should be populated from ctx.Repo")
|
|
assert.Equal(t, repo.FullName(), run.Repository.FullName, "repository full_name should match")
|
|
assert.NotNil(t, run.TriggerActor, "trigger_actor should be populated")
|
|
}
|
|
}
|
|
|
|
func testAPIActionsGetWorkflowRunLogsNotFound(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
session := loginUser(t, user.Name)
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
|
|
|
t.Run("NoLogs", func(t *testing.T) {
|
|
// Run 795 has jobs but fixture tasks have no log output in storage.
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/logs", repo.FullName())).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("RunNotFound", func(t *testing.T) {
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/999999/logs", repo.FullName())).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
})
|
|
}
|
|
|
|
// seedTaskLogs writes task logs the way the runner does, in DBFS to stay independent of the object storage fixture.
|
|
func seedTaskLogs(t *testing.T, taskID int64, lines ...string) {
|
|
t.Helper()
|
|
|
|
task, err := actions_model.GetTaskByID(t.Context(), taskID)
|
|
require.NoError(t, err)
|
|
|
|
task.LogInStorage = false
|
|
task.LogFilename = fmt.Sprintf("test-logs/%d.log", task.ID)
|
|
|
|
rows := make([]*runnerv1.LogRow, 0, len(lines))
|
|
for _, line := range lines {
|
|
rows = append(rows, &runnerv1.LogRow{Time: timestamppb.New(time.Unix(1683636528, 0)), Content: line})
|
|
}
|
|
ns, err := actions.WriteLogs(t.Context(), task.LogFilename, 0, rows)
|
|
require.NoError(t, err)
|
|
|
|
task.LogLength = int64(len(rows))
|
|
for _, n := range ns {
|
|
task.LogIndexes = append(task.LogIndexes, task.LogSize)
|
|
task.LogSize += int64(n)
|
|
}
|
|
require.NoError(t, actions_model.UpdateTask(t.Context(), task,
|
|
"log_filename", "log_in_storage", "log_indexes", "log_length", "log_size"))
|
|
}
|
|
|
|
// the success path is covered against real runner logs by TestDownloadTaskLogs
|
|
func testAPIActionsGetWorkflowJobLogsNotFound(t *testing.T) {
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
session := loginUser(t, user.Name)
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
|
|
|
t.Run("NoLogFile", func(t *testing.T) {
|
|
// job 199 exists but its task has no log file in the test fixture
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/199/logs", repo.FullName())).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("JobNotFound", func(t *testing.T) {
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/jobs/999999/logs", repo.FullName())).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
})
|
|
}
|
|
|
|
// TestAPIOrgActionsRunsAccessControl ensures the org-level Actions run/job listing does not
|
|
// leak runs/jobs from repos the caller cannot access.
|
|
func TestAPIOrgActionsRunsAccessControl(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
// org3 has action run 802 (and its jobs) in the private repo5; user28 is an org3 member
|
|
// (teams 12/13) with no access to repo5.
|
|
token := getUserToken(t, "user28", auth_model.AccessTokenScopeReadOrganization)
|
|
|
|
req := NewRequest(t, "GET", "/api/v1/orgs/org3/actions/runs").AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
runs := DecodeJSON(t, resp, &api.ActionWorkflowRunsResponse{})
|
|
for _, r := range runs.Entries {
|
|
assert.NotEqual(t, int64(802), r.ID, "must not leak a run from an inaccessible repo")
|
|
}
|
|
|
|
req = NewRequest(t, "GET", "/api/v1/orgs/org3/actions/jobs").AddTokenAuth(token)
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
jobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{})
|
|
for _, j := range jobs.Entries {
|
|
assert.NotEqual(t, int64(802), j.RunID, "must not leak a job from an inaccessible repo run")
|
|
}
|
|
|
|
// user1 is a site admin: it normally bypasses the per-repo access filter, but a public-only token
|
|
// must stay confined to public repos, so the run/job in the private repo5 must not be listed.
|
|
adminPublicOnly := getUserToken(t, "user1", auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopePublicOnly)
|
|
|
|
req = NewRequest(t, "GET", "/api/v1/orgs/org3/actions/runs").AddTokenAuth(adminPublicOnly)
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
adminRuns := DecodeJSON(t, resp, &api.ActionWorkflowRunsResponse{})
|
|
for _, r := range adminRuns.Entries {
|
|
assert.NotEqual(t, int64(802), r.ID, "a public-only admin token must not list a private repo's run")
|
|
}
|
|
|
|
req = NewRequest(t, "GET", "/api/v1/orgs/org3/actions/jobs").AddTokenAuth(adminPublicOnly)
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
adminJobs := DecodeJSON(t, resp, &api.ActionWorkflowJobsResponse{})
|
|
for _, j := range adminJobs.Entries {
|
|
assert.NotEqual(t, int64(802), j.RunID, "a public-only admin token must not list a private repo's job")
|
|
}
|
|
}
|