feat: update gitea vendor
runner nix smoke / nix label and flake smoke (push) Failing after 1m28s

This commit is contained in:
2026-09-26 21:18:24 +00:00
parent c439c1b948
commit d9b2a4e787
3538 changed files with 116131 additions and 44340 deletions
@@ -1,106 +0,0 @@
# Integration tests
Integration tests can be run with make commands for the
appropriate backends, namely:
```shell
make test-sqlite
make test-pgsql
make test-mysql
make test-mssql
```
Make sure to perform a clean build before running tests:
```
make clean build
```
## Run tests via local act_runner
### Run all jobs
```
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest
```
Warning: This file defines many jobs, so it will be resource-intensive and therefor not recommended.
### Run single job
```SHELL
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest -j <job_name>
```
You can list all job names via:
```SHELL
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest -l
```
## Run sqlite integration tests
Start tests
```
make test-sqlite
```
## Run MySQL integration tests
Setup a MySQL database inside docker
```
docker run -e "MYSQL_DATABASE=test" -e "MYSQL_ALLOW_EMPTY_PASSWORD=yes" -p 3306:3306 --rm --name mysql mysql:latest #(just ctrl-c to stop db and clean the container)
docker run -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" --rm --name elasticsearch elasticsearch:7.6.0 #(in a second terminal, just ctrl-c to stop db and clean the container)
```
Start tests based on the database container
```
TEST_MYSQL_HOST=localhost:3306 TEST_MYSQL_DBNAME=test TEST_MYSQL_USERNAME=root TEST_MYSQL_PASSWORD='' make test-mysql
```
## Run pgsql integration tests
Setup a pgsql database inside docker
```
docker run -e "POSTGRES_DB=test" -e "POSTGRES_USER=postgres" -e "POSTGRES_PASSWORD=postgres" -p 5432:5432 --rm --name pgsql postgres:latest #(just ctrl-c to stop db and clean the container)
```
Setup minio inside docker
```
docker run --rm -p 9000:9000 -e MINIO_ROOT_USER=123456 -e MINIO_ROOT_PASSWORD=12345678 --name minio bitnamilegacy/minio:2023.8.31
```
Start tests based on the database container
```
TEST_MINIO_ENDPOINT=localhost:9000 TEST_PGSQL_HOST=localhost:5432 TEST_PGSQL_DBNAME=postgres TEST_PGSQL_USERNAME=postgres TEST_PGSQL_PASSWORD=postgres make test-pgsql
```
## Run mssql integration tests
Setup a mssql database inside docker
```
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_PID=Standard" -e "SA_PASSWORD=MwantsaSecurePassword1" -p 1433:1433 --rm --name mssql microsoft/mssql-server-linux:latest #(just ctrl-c to stop db and clean the container)
```
Start tests based on the database container
```
TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=gitea_test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-mssql
```
## Running individual tests
Example command to run GPG test:
For SQLite:
```
make test-sqlite#GPG
```
For other databases(replace `mssql` to `mysql`, or `pgsql`):
```
TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-mssql#GPG
```
## Setting timeouts for declaring long-tests and long-flushes
We appreciate that some testing machines may not be very powerful and
the default timeouts for declaring a slow test or a slow clean-up flush
may not be appropriate.
You can set the following environment variables:
```bash
GITEA_TEST_SLOW_RUN="10s" GITEA_TEST_SLOW_FLUSH="1s" make test-sqlite
```
@@ -1,91 +0,0 @@
# 关于集成测试
使用如下 make 命令可以运行指定的集成测试:
```shell
make test-mysql
make test-pgsql
make test-sqlite
```
在执行集成测试命令前请确保清理了之前的构建环境,清理命令如下:
```
make clean build
```
## 如何在本地 act_runner 上运行测试
### 运行所有任务
```
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest
```
警告:由于在此文件中定义了许多任务,因此此操作将花费太多的CPU和内存来运行。所以不建议这样做。
### 运行单个任务
```SHELL
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest -j <job_name>
```
您可以通过以下方式列出所有任务名称:
```SHELL
act_runner exec -W ./.github/workflows/pull-db-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest -l
```
## 如何使用 sqlite 数据库进行集成测试
使用该命令执行集成测试
```
make test-sqlite
```
## 如何使用 mysql 数据库进行集成测试
首先在docker容器里部署一个 mysql 数据库
```
docker run -e "MYSQL_DATABASE=test" -e "MYSQL_ALLOW_EMPTY_PASSWORD=yes" -p 3306:3306 --rm --name mysql mysql:8 #(just ctrl-c to stop db and clean the container)
```
之后便可以基于这个数据库进行集成测试
```
TEST_MYSQL_HOST=localhost:3306 TEST_MYSQL_DBNAME=test TEST_MYSQL_USERNAME=root TEST_MYSQL_PASSWORD='' make test-mysql
```
## 如何使用 pgsql 数据库进行集成测试
同上,首先在 docker 容器里部署一个 pgsql 数据库
```
docker run -e "POSTGRES_DB=test" -e "POSTGRES_USER=postgres" -e "POSTGRES_PASSWORD=postgres" -p 5432:5432 --rm --name pgsql postgres:latest #(just ctrl-c to stop db and clean the container)
```
在docker内设置minio
```
docker run --rm -p 9000:9000 -e MINIO_ROOT_USER=123456 -e MINIO_ROOT_PASSWORD=12345678 --name minio bitnamilegacy/minio:2023.8.31
```
之后便可以基于这个数据库进行集成测试
```
TEST_MINIO_ENDPOINT=localhost:9000 TEST_PGSQL_HOST=localhost:5432 TEST_PGSQL_DBNAME=postgres TEST_PGSQL_USERNAME=postgres TEST_PGSQL_PASSWORD=postgres make test-pgsql
```
## Run mssql integration tests
同上,首先在 docker 容器里部署一个 mssql 数据库
```
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_PID=Standard" -e "SA_PASSWORD=MwantsaSecurePassword1" -p 1433:1433 --rm --name mssql microsoft/mssql-server-linux:latest #(just ctrl-c to stop db and clean the container)
```
之后便可以基于这个数据库进行集成测试
```
TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=gitea_test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-mssql
```
## 如何进行自定义的集成测试
下面的示例展示了怎样在集成测试中只进行 GPG 测试:
sqlite 数据库:
```
make test-sqlite#GPG
```
其它数据库(把 MSSQL 替换为 MYSQL, PGSQL):
```
TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-mssql#GPG
```
@@ -0,0 +1,3 @@
Content-Type: application/json
{"id":1,"name":"test_repo","full_name":"gitea/test_repo","owner":{"id":1,"login":"gitea","username":"gitea"},"private":false,"html_url":"https://gitea.com/gitea/test_repo","clone_url":"https://gitea.com/gitea/test_repo.git","default_branch":"master","description":"test repo for migration"}
@@ -0,0 +1,3 @@
Content-Type: application/json
[{"id":1553,"body":"TESTSET for gitea2gitea migration\n","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z"},{"id":1554,"body":"Oh!\n","user":{"id":-1,"login":"Ghost","full_name":"","email":"","avatar_url":"","username":"Ghost"},"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z"}]
@@ -0,0 +1,3 @@
Content-Type: application/json
[{"user":{"id":-1,"login":"Ghost","full_name":"","email":"","avatar_url":"","username":"Ghost"},"content":"gitea"},{"user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"content":"laugh"}]
@@ -0,0 +1,3 @@
Content-Type: application/json
[{"number":1,"title":"issue1","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false},{"number":2,"title":"issue2","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false},{"number":3,"title":"issue3","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false},{"number":4,"title":"what is this repo about?","body":"","state":"closed","user":{"id":-1,"login":"Ghost","full_name":"","email":"","avatar_url":"","username":"Ghost"},"labels":[{"id":2,"name":"Question","color":"#d876e3"}],"milestone":{"id":1,"title":"V1"},"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","closed_at":"2020-01-01T00:00:00Z","is_locked":true},{"number":5,"title":"issue5","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false},{"number":6,"title":"issue6","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false},{"number":7,"title":"issue7","body":"","state":"open","user":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"labels":[],"created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","is_locked":false}]
@@ -0,0 +1,3 @@
Content-Type: application/json
[{"id":1,"name":"Bug","color":"#ee0701","description":""},{"id":2,"name":"Question","color":"#d876e3","description":""}]
@@ -0,0 +1,3 @@
Content-Type: application/json
[{"id":1,"title":"V1","description":"first milestone","state":"closed","created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z","closed_at":"2020-01-01T00:00:00Z"},{"id":2,"title":"V2 Finalize","description":"second milestone","state":"open","created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-01T00:00:00Z"}]
@@ -0,0 +1,3 @@
Content-Type: application/json
[{"id":1,"tag_name":"V1","target_commitish":"master","name":"First Release","body":"as title","draft":false,"prerelease":false,"created_at":"2020-01-01T00:00:00Z","published_at":"2020-01-01T00:00:00Z","author":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"assets":[]},{"id":2,"tag_name":"v2-rc1","target_commitish":"master","name":"Second Release","body":"this repo has:\n- issues\n- pulls","draft":false,"prerelease":true,"created_at":"2020-01-01T00:00:00Z","published_at":"2020-01-01T00:00:00Z","author":{"id":689,"login":"6543","full_name":"","email":"6543@obermui.de","avatar_url":"","username":"6543"},"assets":[]}]
@@ -0,0 +1,3 @@
Content-Type: application/json
{"max_response_items":50,"default_paging_num":30,"default_git_trees_per_page":40,"default_max_blob_size":10485760}
@@ -0,0 +1,3 @@
Content-Type: application/json
{"version":"1.22.0"}
@@ -11,12 +11,12 @@ import (
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"github.com/stretchr/testify/assert"
)
@@ -70,8 +70,7 @@ jobs:
Name: new("approve-all-runs-fork"),
}).AddTokenAuth(user4Token)
resp := MakeRequest(t, req, http.StatusAccepted)
var apiForkRepo api.Repository
DecodeJSON(t, resp, &apiForkRepo)
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(user4APICtx)(t)
@@ -140,3 +139,107 @@ jobs:
assert.Equal(t, actions_model.StatusWaiting, run2.Status)
})
}
// TestForkPullRequestApprovalNotBypassedByPriorApproval verifies that a single
// approval on a fork PR does not permanently trust the contributor: a subsequent
// fork PR from the same user must still be gated (Blocked / NeedApproval=true)
// until that user has had a pull request merged in the repo.
func TestForkPullRequestApprovalNotBypassedByPriorApproval(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user2Session := loginUser(t, user2.Name)
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
user4Session := loginUser(t, user4.Name)
user4Token := getTokenForLoggedInUser(t, user4Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiBaseRepo := createActionsTestRepo(t, user2Token, "fork-approval-regression", false)
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID})
user2APICtx := NewAPITestContext(t, baseRepo.OwnerName, baseRepo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(user2APICtx)(t)
wfTreePath := ".gitea/workflows/ci.yml"
wfContent := `name: CI
on: pull_request
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo ok
`
createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, wfTreePath,
getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, "add ci", wfContent))
// user4 forks the repo
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name),
&api.CreateForkOption{Name: new("fork-approval-regression-fork")}).AddTokenAuth(user4Token)
resp := MakeRequest(t, req, http.StatusAccepted)
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(user4APICtx)(t)
// PR #1: a benign change from user4's fork — first-time contributor, gate engages.
doAPICreateFile(user4APICtx, "first.txt", &api.CreateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: "first",
Message: "first",
Author: api.Identity{Name: user4.Name, Email: user4.Email},
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("first")),
})(t)
pr1, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":first")(t)
assert.NoError(t, err)
run1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr1.Index)})
assert.True(t, run1.NeedApproval, "first fork PR must require approval")
assert.Equal(t, actions_model.StatusBlocked, run1.Status)
// user2 approves run1.
req = NewRequest(t, "POST", fmt.Sprintf("%s/actions/approve-all-checks?commit_id=%s", baseRepo.Link(), pr1.Head.Sha))
user2Session.MakeRequest(t, req, http.StatusOK)
run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run1.ID})
assert.False(t, run1.NeedApproval)
assert.Equal(t, user2.ID, run1.ApprovedBy)
// PR #2: same user, fresh branch. Pre-fix, this run was created with
// NeedApproval=false and dispatched immediately — the bypass path.
doAPICreateFile(user4APICtx, "second.txt", &api.CreateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: "second",
Message: "second",
Author: api.Identity{Name: user4.Name, Email: user4.Email},
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("second")),
})(t)
pr2, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":second")(t)
assert.NoError(t, err)
run2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr2.Index)})
assert.True(t, run2.NeedApproval, "second fork PR must still require approval — prior approval-to-run does not grant trust")
assert.Equal(t, actions_model.StatusBlocked, run2.Status)
assert.EqualValues(t, 0, run2.ApprovedBy)
// After merging PR #1, user4 becomes a known contributor and the gate lifts for a new PR.
doAPIMergePullRequest(user2APICtx, baseRepo.OwnerName, baseRepo.Name, pr1.Index)(t)
doAPICreateFile(user4APICtx, "third.txt", &api.CreateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: "third",
Message: "third",
Author: api.Identity{Name: user4.Name, Email: user4.Email},
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("third")),
})(t)
pr3, err := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":third")(t)
assert.NoError(t, err)
run3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr3.Index)})
assert.False(t, run3.NeedApproval, "fork PR from a user with a prior merged PR should not require approval")
})
}
@@ -11,19 +11,22 @@ import (
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil"
webhook_module "code.gitea.io/gitea/modules/webhook"
actions_service "code.gitea.io/gitea/services/actions"
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/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
webhook_module "gitea.dev/modules/webhook"
actions_web "gitea.dev/routers/web/repo/actions"
actions_service "gitea.dev/services/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWorkflowConcurrency(t *testing.T) {
@@ -96,7 +99,7 @@ jobs:
// fetch and exec workflow1
task := runner.fetchTask(t)
_, _, run := getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-1.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -109,7 +112,7 @@ jobs:
// fetch workflow2
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-2.yml", run.WorkflowID)
// push workflow3
@@ -125,7 +128,7 @@ jobs:
// fetch and exec workflow3
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-3.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -201,7 +204,7 @@ jobs:
// fetch and exec workflow1
task := runner.fetchTask(t)
_, _, run := getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-1.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -214,7 +217,7 @@ jobs:
// fetch workflow2
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-2.yml", run.WorkflowID)
// push workflow3
@@ -230,7 +233,7 @@ jobs:
// fetch and exec workflow3
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-3.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -318,7 +321,7 @@ jobs:
// fetch and exec workflow1
task := runner.fetchTask(t)
_, _, run := getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-1.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -331,7 +334,7 @@ jobs:
// fetch workflow2
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-2.yml", run.WorkflowID)
// push workflow3
@@ -347,7 +350,7 @@ jobs:
// fetch and exec workflow3
task = runner.fetchTask(t)
_, _, run = getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "workflow-main-abc123-user2", run.ConcurrencyGroup)
assert.Equal(t, "workflow-main-abc123-user2", getRunConcurrencyGroup(t, run))
assert.Equal(t, "concurrent-workflow-3.yml", run.WorkflowID)
runner.fetchNoTask(t)
runner.execTask(t, task, &mockTaskOutcome{
@@ -412,8 +415,8 @@ jobs:
doAPICreatePullRequest(user2APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, "bugfix/aaa")(t)
pr1Task1 := runner.fetchTask(t)
_, _, pr1Run1 := getTaskAndJobAndRunByTaskID(t, pr1Task1.Id)
assert.Equal(t, "pull-request-test", pr1Run1.ConcurrencyGroup)
assert.True(t, pr1Run1.ConcurrencyCancel)
assert.Equal(t, "pull-request-test", getRunConcurrencyGroup(t, pr1Run1))
assert.True(t, getRunConcurrencyCancel(t, pr1Run1))
assert.Equal(t, actions_model.StatusRunning, pr1Run1.Status)
// user4 forks the repo
@@ -422,8 +425,7 @@ jobs:
Name: new("actions-concurrency-fork"),
}).AddTokenAuth(user4Token)
resp := MakeRequest(t, req, http.StatusAccepted)
var apiForkRepo api.Repository
DecodeJSON(t, resp, &apiForkRepo)
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(user4APICtx)(t)
@@ -458,8 +460,8 @@ jobs:
// fetch the task and the previous task has been cancelled
pr2Task1 := runner.fetchTask(t)
_, _, pr2Run1 = getTaskAndJobAndRunByTaskID(t, pr2Task1.Id)
assert.Equal(t, "pull-request-test", pr2Run1.ConcurrencyGroup)
assert.True(t, pr2Run1.ConcurrencyCancel)
assert.Equal(t, "pull-request-test", getRunConcurrencyGroup(t, pr2Run1))
assert.True(t, getRunConcurrencyCancel(t, pr2Run1))
assert.Equal(t, actions_model.StatusRunning, pr2Run1.Status)
pr1Run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: pr1Run1.ID})
assert.Equal(t, actions_model.StatusCancelled, pr1Run1.Status)
@@ -484,19 +486,23 @@ jobs:
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("user4-fix2")),
})(t)
doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":do-not-cancel/ccc")(t)
// cannot fetch the task because cancel-in-progress is false
pr3, _ := doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":do-not-cancel/ccc")(t)
// cannot fetch the task: approval still required (user4 has no merged PR) and cancel-in-progress is false
runner.fetchNoTask(t)
runner.execTask(t, pr2Task1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
pr2Run1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: pr2Run1.ID})
assert.Equal(t, actions_model.StatusSuccess, pr2Run1.Status)
// user2 approves the third PR's run (user4 still has no merged PR, approval still required)
pr3Run1Pending := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID, Ref: fmt.Sprintf("refs/pull/%d/head", pr3.Index)})
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/approve", baseRepo.OwnerName, baseRepo.Name, pr3Run1Pending.ID))
user2Session.MakeRequest(t, req, http.StatusOK)
// fetch the task
pr3Task1 := runner.fetchTask(t)
_, _, pr3Run1 := getTaskAndJobAndRunByTaskID(t, pr3Task1.Id)
assert.Equal(t, "pull-request-test", pr3Run1.ConcurrencyGroup)
assert.False(t, pr3Run1.ConcurrencyCancel)
assert.Equal(t, "pull-request-test", getRunConcurrencyGroup(t, pr3Run1))
assert.False(t, getRunConcurrencyCancel(t, pr3Run1))
assert.Equal(t, actions_model.StatusRunning, pr3Run1.Status)
})
}
@@ -643,6 +649,7 @@ jobs:
assert.Equal(t, "job-main-v1.24.0", wf2Job2Rerun1Job.ConcurrencyGroup)
// rerun wf2-job2
wf2Job2ActionJob = getLatestAttemptJobByTemplateJobID(t, wf2Run.ID, wf2Job2ActionJob.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, wf2Run.ID, wf2Job2ActionJob.ID))
_ = session.MakeRequest(t, req, http.StatusOK)
// (rerun2) fetch and exec wf2-job2
@@ -803,7 +810,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task1 := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
assert.Equal(t, "workflow-dispatch-v1.21", run1.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.21", getRunConcurrencyGroup(t, run1))
// run the workflow with appVersion=v1.22 and cancel=false
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
@@ -813,7 +820,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task2 := runner.fetchTask(t)
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run2.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run2))
// run the workflow with appVersion=v1.22 and cancel=false again
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
@@ -832,7 +839,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task4 := runner.fetchTask(t)
_, _, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run4.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4))
_, _, run2 = getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
})
@@ -893,7 +900,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task1 := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
assert.Equal(t, "workflow-dispatch-v1.21", run1.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.21", getRunConcurrencyGroup(t, run1))
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
"ref": "refs/heads/main",
@@ -902,7 +909,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task2 := runner.fetchTask(t)
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run2.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run2))
// run the workflow with appVersion=v1.22 and cancel=false again
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
@@ -927,7 +934,7 @@ jobs:
task4 := runner.fetchTask(t)
_, _, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
assert.Equal(t, actions_model.StatusRunning, run4.Status)
assert.Equal(t, "workflow-dispatch-v1.22", run4.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4))
_, _, run2 = getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
@@ -945,7 +952,7 @@ jobs:
task5 := runner.fetchTask(t)
_, _, run4_1 := getTaskAndJobAndRunByTaskID(t, task5.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run4_1.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4_1))
assert.Equal(t, run4.ID, run4_1.ID)
_, _, run2_1 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, actions_model.StatusCancelled, run2_1.Status)
@@ -969,7 +976,7 @@ jobs:
_, _, run3_2 := getTaskAndJobAndRunByTaskID(t, task6.Id)
assert.Equal(t, run3.ID, run3_2.ID)
assert.Equal(t, actions_model.StatusRunning, run3_2.Status)
assert.Equal(t, "workflow-dispatch-v1.22", run3.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run3))
run2_2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2_2.ID})
assert.Equal(t, actions_model.StatusCancelled, run2_2.Status) // cancelled by run3
@@ -1031,7 +1038,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task1 := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
assert.Equal(t, "workflow-dispatch-v1.21", run1.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.21", getRunConcurrencyGroup(t, run1))
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
"ref": "refs/heads/main",
@@ -1040,7 +1047,7 @@ jobs:
session.MakeRequest(t, req, http.StatusSeeOther)
task2 := runner.fetchTask(t)
_, job2, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run2.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run2))
// run the workflow with appVersion=v1.22 and cancel=false again
req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
@@ -1065,7 +1072,7 @@ jobs:
task4 := runner.fetchTask(t)
_, job4, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
assert.Equal(t, actions_model.StatusRunning, run4.Status)
assert.Equal(t, "workflow-dispatch-v1.22", run4.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4))
_, _, run2 = getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
@@ -1074,15 +1081,17 @@ jobs:
})
// rerun cancel true scenario
job2 = getLatestAttemptJobByTemplateJobID(t, run2.ID, job2.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run2.ID, job2.ID))
_ = session.MakeRequest(t, req, http.StatusOK)
job4 = getLatestAttemptJobByTemplateJobID(t, run4.ID, job4.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run4.ID, job4.ID))
_ = session.MakeRequest(t, req, http.StatusOK)
task5 := runner.fetchTask(t)
_, _, run4_1 := getTaskAndJobAndRunByTaskID(t, task5.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run4_1.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run4_1))
assert.Equal(t, run4.ID, run4_1.ID)
_, _, run2_1 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, actions_model.StatusCancelled, run2_1.Status)
@@ -1093,18 +1102,20 @@ jobs:
// rerun cancel false scenario
job2 = getLatestAttemptJobByTemplateJobID(t, run2.ID, job2.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run2.ID, job2.ID))
_ = session.MakeRequest(t, req, http.StatusOK)
run2_2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2.ID})
assert.Equal(t, actions_model.StatusWaiting, run2_2.Status)
job3 = getLatestAttemptJobByTemplateJobID(t, run3.ID, job3.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, apiRepo.Name, run3.ID, job3.ID))
_ = session.MakeRequest(t, req, http.StatusOK)
task6 := runner.fetchTask(t)
_, _, run3 = getTaskAndJobAndRunByTaskID(t, task6.Id)
assert.Equal(t, "workflow-dispatch-v1.22", run3.ConcurrencyGroup)
assert.Equal(t, "workflow-dispatch-v1.22", getRunConcurrencyGroup(t, run3))
run2_2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run2_2.ID})
assert.Equal(t, actions_model.StatusCancelled, run2_2.Status) // cancelled by run3
@@ -1147,8 +1158,8 @@ jobs:
// fetch the task triggered by push
task1 := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
assert.Equal(t, "schedule-concurrency", run1.ConcurrencyGroup)
assert.True(t, run1.ConcurrencyCancel)
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run1))
assert.True(t, getRunConcurrencyCancel(t, run1))
assert.Equal(t, string(webhook_module.HookEventPush), run1.TriggerEvent)
assert.Equal(t, actions_model.StatusRunning, run1.Status)
@@ -1165,8 +1176,8 @@ jobs:
assert.Equal(t, actions_model.StatusSuccess, run1.Status)
task2 := runner.fetchTask(t)
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, "schedule-concurrency", run2.ConcurrencyGroup)
assert.False(t, run2.ConcurrencyCancel)
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run2))
assert.False(t, getRunConcurrencyCancel(t, run2))
assert.Equal(t, string(webhook_module.HookEventSchedule), run2.TriggerEvent)
assert.Equal(t, actions_model.StatusRunning, run2.Status)
@@ -1177,8 +1188,8 @@ jobs:
assert.NoError(t, actions_service.StartScheduleTasks(t.Context()))
runner.fetchNoTask(t) // cannot fetch because task2 is not completed
run3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID, Status: actions_model.StatusBlocked})
assert.Equal(t, "schedule-concurrency", run3.ConcurrencyGroup)
assert.False(t, run3.ConcurrencyCancel)
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run3))
assert.False(t, getRunConcurrencyCancel(t, run3))
assert.Equal(t, string(webhook_module.HookEventSchedule), run3.TriggerEvent)
// trigger the task by push
@@ -1204,8 +1215,8 @@ jobs:
task4 := runner.fetchTask(t)
_, _, run4 := getTaskAndJobAndRunByTaskID(t, task4.Id)
assert.Equal(t, "schedule-concurrency", run4.ConcurrencyGroup)
assert.True(t, run4.ConcurrencyCancel)
assert.Equal(t, "schedule-concurrency", getRunConcurrencyGroup(t, run4))
assert.True(t, getRunConcurrencyCancel(t, run4))
assert.Equal(t, string(webhook_module.HookEventPush), run4.TriggerEvent)
run3 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run3.ID})
assert.Equal(t, actions_model.StatusCancelled, run3.Status)
@@ -1317,7 +1328,7 @@ jobs:
w1j2Task := runner2.fetchTask(t)
_, w1j1Job, w1Run := getTaskAndJobAndRunByTaskID(t, w1j1Task.Id)
assert.Equal(t, "job-group-1", w1j1Job.ConcurrencyGroup)
assert.Equal(t, "workflow-group-1", w1Run.ConcurrencyGroup)
assert.Equal(t, "workflow-group-1", getRunConcurrencyGroup(t, w1Run))
assert.Equal(t, "concurrent-workflow-1.yml", w1Run.WorkflowID)
assert.Equal(t, actions_model.StatusRunning, w1j1Job.Status)
_, w1j2Job, _ := getTaskAndJobAndRunByTaskID(t, w1j2Task.Id)
@@ -1358,7 +1369,7 @@ jobs:
w3j1Task := runner1.fetchTask(t)
_, w3j1Job, w3Run = getTaskAndJobAndRunByTaskID(t, w3j1Task.Id)
assert.Equal(t, "job-group-1", w3j1Job.ConcurrencyGroup)
assert.Equal(t, "workflow-group-2", w3Run.ConcurrencyGroup)
assert.Equal(t, "workflow-group-2", getRunConcurrencyGroup(t, w3Run))
assert.Equal(t, "concurrent-workflow-3.yml", w3Run.WorkflowID)
// exec wf1-job2
@@ -1370,7 +1381,7 @@ jobs:
w2j2Task := runner2.fetchTask(t)
_, w2j2Job, w2Run := getTaskAndJobAndRunByTaskID(t, w2j2Task.Id)
assert.Equal(t, "job-group-2", w2j2Job.ConcurrencyGroup)
assert.Equal(t, "workflow-group-1", w2Run.ConcurrencyGroup)
assert.Equal(t, "workflow-group-1", getRunConcurrencyGroup(t, w2Run))
assert.Equal(t, "concurrent-workflow-2.yml", w2Run.WorkflowID)
assert.Equal(t, actions_model.StatusRunning, w2j2Job.Status)
@@ -1397,7 +1408,7 @@ jobs:
assert.Equal(t, actions_model.StatusCancelled, w2Run.Status)
_, w4j1Job, w4Run := getTaskAndJobAndRunByTaskID(t, w4j1Task.Id)
assert.Equal(t, "job-group-2", w4j1Job.ConcurrencyGroup)
assert.Equal(t, "workflow-group-2", w4Run.ConcurrencyGroup)
assert.Equal(t, "workflow-group-2", getRunConcurrencyGroup(t, w4Run))
assert.Equal(t, "concurrent-workflow-4.yml", w4Run.WorkflowID)
})
}
@@ -1435,8 +1446,8 @@ jobs:
// fetch and check the first task
task1 := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, task1.Id)
assert.Equal(t, "cancel-run-group", run1.ConcurrencyGroup)
assert.False(t, run1.ConcurrencyCancel)
assert.Equal(t, "cancel-run-group", getRunConcurrencyGroup(t, run1))
assert.False(t, getRunConcurrencyCancel(t, run1))
assert.Equal(t, actions_model.StatusRunning, run1.Status)
// push another file to trigger the workflow again
@@ -1473,8 +1484,8 @@ jobs:
// fetch and check the second task
task2 := runner.fetchTask(t)
_, _, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, "cancel-run-group", run2.ConcurrencyGroup)
assert.False(t, run2.ConcurrencyCancel)
assert.Equal(t, "cancel-run-group", getRunConcurrencyGroup(t, run2))
assert.False(t, getRunConcurrencyCancel(t, run2))
assert.Equal(t, actions_model.StatusRunning, run2.Status)
})
}
@@ -1533,7 +1544,7 @@ jobs:
// fetch wf1-job1
w1j1Task := runner.fetchTask(t)
_, _, run1 := getTaskAndJobAndRunByTaskID(t, w1j1Task.Id)
assert.Equal(t, "test-group", run1.ConcurrencyGroup)
assert.Equal(t, "test-group", getRunConcurrencyGroup(t, run1))
assert.Equal(t, actions_model.StatusRunning, run1.Status)
// query wf1-job2 from db and check its status
w1j2Job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run1.ID, JobID: "wf1-job2"})
@@ -1553,6 +1564,9 @@ jobs:
// run2 is blocked because it is blocked by workflow1's concurrency group "test-group"
assert.Equal(t, actions_model.StatusBlocked, run2.Status)
// complete wf1-job1
runner.execTask(t, w1j1Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
// mock time
fakeNow := now.Add(setting.Actions.AbandonedJobTimeout)
timeutil.MockSet(fakeNow)
@@ -1571,7 +1585,7 @@ jobs:
// fetch wf2-job1 and check
w2j1Task := runner.fetchTask(t)
_, w2j1Job, run2 := getTaskAndJobAndRunByTaskID(t, w2j1Task.Id)
assert.Equal(t, "test-group", run2.ConcurrencyGroup)
assert.Equal(t, "test-group", getRunConcurrencyGroup(t, run2))
assert.Equal(t, "wf2-job1", w2j1Job.JobID)
assert.Equal(t, actions_model.StatusRunning, run2.Status)
assert.Equal(t, actions_model.StatusRunning, w2j1Job.Status)
@@ -1650,7 +1664,7 @@ jobs:
// cannot fetch run2 because run1 is still running
runner.fetchNoTask(t)
run2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID, WorkflowID: "concurrent-workflow-2.yml"})
assert.Equal(t, "test-group", run2.ConcurrencyGroup)
assert.Equal(t, "test-group", getRunConcurrencyGroup(t, run2))
assert.Equal(t, actions_model.StatusBlocked, run2.Status)
// exec run1
@@ -1677,3 +1691,164 @@ jobs:
assert.Equal(t, actions_model.StatusCancelled, run2.Status)
})
}
// TestCancelLegacyRunBlockedByConcurrency simulates a workflow run created before migration v331:
// it has no ActionRunAttempt record (LatestAttemptID == 0) and was blocked by workflow-level concurrency.
// Migration v331 drops action_run.concurrency_group / concurrency_cancel, so the run ends up "stuck" with no way for the job emitter to naturally unblock it.
// The test verifies the user can still:
// 1. view the stuck legacy run correctly (web view renders)
// 2. cancel it from the UI, which transitions the run and all its jobs to Cancelled
// 3. rerun the (now cancelled) legacy run successfully
func TestCancelLegacyRunBlockedByConcurrency(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-legacy-concurrency", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(httpContext)(t)
runner := newMockRunner()
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
// Manually insert a "legacy" run blocked by workflow-level concurrency: no ActionRunAttempt, LatestAttemptID=0.
// Its workflow-level concurrency info would have been stored on action_run.concurrency_group pre-v331;
// after the migration that column is gone, so we simply mark the run (and its jobs) as Blocked.
legacyWfContent := `name: legacy-blocked
on:
workflow_dispatch:
concurrency:
group: test-group
jobs:
legacy-job1:
runs-on: ubuntu-latest
steps:
- run: echo 'legacy-job1'
legacy-job2:
runs-on: ubuntu-latest
steps:
- run: echo 'legacy-job2'
`
payloads := mustParseSingleWorkflowPayloads(t, legacyWfContent)
now := timeutil.TimeStamp(time.Now().Unix())
legacyRun := &actions_model.ActionRun{
Title: "legacy blocked run",
RepoID: repo.ID,
OwnerID: repo.OwnerID,
WorkflowID: "legacy-blocked.yml",
Index: 1,
TriggerUserID: user2.ID,
Ref: "refs/heads/" + repo.DefaultBranch,
CommitSHA: "0000000000000000000000000000000000000000",
Event: "workflow_dispatch",
TriggerEvent: "workflow_dispatch",
EventPayload: "{}",
Status: actions_model.StatusBlocked,
Created: now - 1,
Updated: now - 1,
}
require.NoError(t, db.Insert(t.Context(), legacyRun))
legacyJob1 := &actions_model.ActionRunJob{
RunID: legacyRun.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
Name: payloads["legacy-job1"].name,
Attempt: 1,
WorkflowPayload: payloads["legacy-job1"].payload,
JobID: "legacy-job1",
Needs: payloads["legacy-job1"].needs,
RunsOn: payloads["legacy-job1"].runsOn,
Status: actions_model.StatusBlocked,
RunAttemptID: 0,
AttemptJobID: 0,
}
legacyJob2 := &actions_model.ActionRunJob{
RunID: legacyRun.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
Name: payloads["legacy-job2"].name,
Attempt: 1,
WorkflowPayload: payloads["legacy-job2"].payload,
JobID: "legacy-job2",
Needs: payloads["legacy-job2"].needs,
RunsOn: payloads["legacy-job2"].runsOn,
Status: actions_model.StatusBlocked,
RunAttemptID: 0,
AttemptJobID: 0,
}
require.NoError(t, db.Insert(t.Context(), legacyJob1, legacyJob2))
// 1) User visits the legacy run's web view - it renders without error.
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, legacyRun.ID))
resp := session.MakeRequest(t, req, http.StatusOK)
viewResp := DecodeJSON(t, resp, &actions_web.ViewResponse{})
// Legacy run has no attempt record, so RunAttempt is 0 and Attempts is empty.
assert.EqualValues(t, 0, viewResp.State.Run.RunAttempt)
assert.Empty(t, viewResp.State.Run.Attempts)
assert.Equal(t, actions_model.StatusBlocked.String(), viewResp.State.Run.Status)
assert.False(t, viewResp.State.Run.Done)
// Legacy workflow-level concurrency info is gone (columns dropped by v331), so GetEffectiveConcurrency returns "": the run cannot self-unblock via job_emitter.
afterLoadRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
assert.Empty(t, getRunConcurrencyGroup(t, afterLoadRun))
// Still Blocked, not Done, but user should be able to cancel.
assert.True(t, viewResp.State.Run.CanCancel)
assert.False(t, viewResp.State.Run.CanRerun)
if assert.Len(t, viewResp.State.Run.Jobs, 2) {
assert.Equal(t, actions_model.StatusBlocked.String(), viewResp.State.Run.Jobs[0].Status)
assert.Equal(t, actions_model.StatusBlocked.String(), viewResp.State.Run.Jobs[1].Status)
}
// 2) User cancels the legacy run to clean it up.
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/cancel", user2.Name, repo.Name, legacyRun.ID))
session.MakeRequest(t, req, http.StatusOK)
// Run and all its jobs transition to Cancelled.
cancelledRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
assert.Equal(t, actions_model.StatusCancelled, cancelledRun.Status)
cancelledJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob1.ID})
assert.Equal(t, actions_model.StatusCancelled, cancelledJob1.Status)
cancelledJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob2.ID})
assert.Equal(t, actions_model.StatusCancelled, cancelledJob2.Status)
// 3) User reruns the now-cancelled legacy run.
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, legacyRun.ID))
session.MakeRequest(t, req, http.StatusOK)
rerunRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
assert.Positive(t, rerunRun.LatestAttemptID)
assert.EqualValues(t, 2, getRunLatestAttemptNum(t, legacyRun.ID))
// Both jobs run successfully on the registered runner.
for range 2 {
task := runner.fetchTask(t)
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
}
finalRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
assert.Equal(t, actions_model.StatusSuccess, finalRun.Status)
})
}
func getRunConcurrencyGroup(t *testing.T, run *actions_model.ActionRun) string {
cg, _, err := run.GetEffectiveConcurrency(t.Context())
assert.NoError(t, err)
return cg
}
func getRunConcurrencyCancel(t *testing.T, run *actions_model.ActionRun) bool {
_, cc, err := run.GetEffectiveConcurrency(t.Context())
assert.NoError(t, err)
return cc
}
func getLatestAttemptJobByTemplateJobID(t *testing.T, runID, templateJobID int64) *actions_model.ActionRunJob {
t.Helper()
templateJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: templateJobID, RunID: runID})
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
job, err := actions_model.GetRunJobByAttemptJobID(t.Context(), run.ID, run.LatestAttemptID, templateJob.AttemptJobID)
assert.NoError(t, err)
return job
}
@@ -0,0 +1,118 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"sync"
"testing"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// minimalWorkflowPayload returns the minimal YAML for a single-job workflow with no steps.
func minimalConcurrentWorkflowPayload(jobID string) []byte {
return []byte("on: push\njobs:\n " + jobID + ":\n runs-on: ubuntu-latest\n")
}
// TestCreateTaskForRunnerConcurrentClaim verifies that when multiple runners
// poll simultaneously and all initially see the same first waiting job,
// each runner claims a distinct job rather than all but one being left
// empty-handed. This is the regression test for the race condition where
// runners losing the optimistic-lock on job #1 would receive latestVersion
// and never retry the remaining 49+ jobs.
//
// It lives in tests/integration rather than a unit test because SQLite
// serializes write transactions, so the contended optimistic-lock path this
// guards only runs concurrently against MySQL/PostgreSQL in CI.
func TestCreateTaskForRunnerConcurrentClaim(t *testing.T) {
defer tests.PrepareTestEnv(t)()
const numJobs = 3
run := &actions_model.ActionRun{
Title: "concurrent-claim-test-run",
RepoID: 1,
OwnerID: 2,
WorkflowID: "test.yaml",
Index: 9901,
TriggerUserID: 2,
Ref: "refs/heads/main",
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
Event: "push",
TriggerEvent: "push",
Status: actions_model.StatusWaiting,
}
require.NoError(t, db.Insert(t.Context(), run))
jobs := make([]*actions_model.ActionRunJob, numJobs)
for i := range numJobs {
jobID := "concurrent-job-" + string(rune('a'+i))
jobs[i] = &actions_model.ActionRunJob{
RunID: run.ID,
RepoID: run.RepoID,
OwnerID: run.OwnerID,
CommitSHA: run.CommitSHA,
Name: jobID,
Attempt: 1,
JobID: jobID,
Status: actions_model.StatusWaiting,
RunsOn: []string{"ubuntu-latest"},
WorkflowPayload: minimalConcurrentWorkflowPayload(jobID),
}
require.NoError(t, db.Insert(t.Context(), jobs[i]))
}
runners := make([]*actions_model.ActionRunner, numJobs)
for i := range numJobs {
r := &actions_model.ActionRunner{
UUID: "concurrent-runner-uuid-" + string(rune('a'+i)),
Name: "concurrent-runner-" + string(rune('a'+i)),
AgentLabels: []string{"ubuntu-latest"},
}
r.GenerateAndFillToken()
runners[i] = r
require.NoError(t, db.Insert(t.Context(), runners[i]))
}
// Simulate the burst: all runners call CreateTaskForRunner concurrently,
// as happens when all see the same stale tasksVersion simultaneously.
type result struct {
task *actions_model.ActionTask
ok bool
err error
}
results := make([]result, numJobs)
var wg sync.WaitGroup
for i := range numJobs {
wg.Go(func() {
task, ok, err := actions_model.CreateTaskForRunner(t.Context(), runners[i])
results[i] = result{task, ok, err}
})
}
wg.Wait()
// Every runner must have received a task without error.
claimedJobIDs := make(map[int64]bool)
for i, r := range results {
require.NoError(t, r.err, "runner %d got an unexpected error", i)
require.True(t, r.ok, "runner %d did not get a task even though free jobs exist", i)
require.NotNil(t, r.task)
assert.False(t, claimedJobIDs[r.task.JobID], "job %d was claimed by more than one runner", r.task.JobID)
claimedJobIDs[r.task.JobID] = true
}
assert.Len(t, claimedJobIDs, numJobs, "expected %d distinct jobs to be claimed", numJobs)
// All jobs must now be running with a task assigned.
for _, j := range jobs {
updated := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: j.ID})
assert.Equal(t, actions_model.StatusRunning, updated.Status)
assert.NotZero(t, updated.TaskID)
}
}
@@ -11,14 +11,14 @@ import (
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/routers/web/repo/actions"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/json"
"gitea.dev/routers/web/repo/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -136,7 +136,7 @@ jobs:
runID = run.ID
}
jobs, err := actions_model.GetRunJobsByRunID(t.Context(), runID)
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(t.Context(), apiRepo.ID, runID)
assert.NoError(t, err)
for i := 0; i < len(testCase.outcomes); i++ {
@@ -9,11 +9,11 @@ import (
"net/url"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
)
@@ -13,18 +13,20 @@ import (
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
actions_service "code.gitea.io/gitea/services/actions"
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/git"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
actions_service "gitea.dev/services/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -157,8 +159,7 @@ jobs:
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", user2.Name, apiRepo.Name)).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var actionTaskRespAfter api.ActionTaskResponse
DecodeJSON(t, resp, &actionTaskRespAfter)
actionTaskRespAfter := DecodeJSON(t, resp, &api.ActionTaskResponse{})
for _, apiTask := range actionTaskRespAfter.Entries {
if apiTask.HeadSHA != fileResp.Commit.SHA {
continue
@@ -460,8 +461,7 @@ func getRepoRunnerID(t *testing.T, authToken, ownerName, repoName string) int64
t.Helper()
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners", ownerName, repoName)).AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusOK)
runnerList := api.ActionRunnersResponse{}
DecodeJSON(t, resp, &runnerList)
runnerList := DecodeJSON(t, resp, &api.ActionRunnersResponse{})
require.Len(t, runnerList.Entries, 1)
return runnerList.Entries[0].ID
}
@@ -707,9 +707,8 @@ func createActionsTestRepo(t *testing.T, authToken, repoName string, isPrivate b
DefaultBranch: "main",
}).AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusCreated)
var apiRepo api.Repository
DecodeJSON(t, resp, &apiRepo)
return &apiRepo
apiRepo := DecodeJSON(t, resp, &api.Repository{})
return apiRepo
}
func getWorkflowCreateFileOptions(u *user_model.User, branch, msg, content string) *api.CreateFileOptions {
@@ -738,9 +737,8 @@ func createWorkflowFile(t *testing.T, authToken, ownerName, repoName, treePath s
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", ownerName, repoName, treePath), opts).
AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusCreated)
var fileResponse api.FileResponse
DecodeJSON(t, resp, &fileResponse)
return &fileResponse
fileResponse := DecodeJSON(t, resp, &api.FileResponse{})
return fileResponse
}
// getTaskJobNameByTaskID get the job name of the task by task ID
@@ -750,8 +748,7 @@ func getTaskJobNameByTaskID(t *testing.T, authToken, ownerName, repoName string,
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", ownerName, repoName)).
AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusOK)
var taskRespBefore api.ActionTaskResponse
DecodeJSON(t, resp, &taskRespBefore)
taskRespBefore := DecodeJSON(t, resp, &api.ActionTaskResponse{})
for _, apiTask := range taskRespBefore.Entries {
if apiTask.ID == taskID {
return apiTask.Name
@@ -759,3 +756,161 @@ func getTaskJobNameByTaskID(t *testing.T, authToken, ownerName, repoName string,
}
return ""
}
// TestLegacyRunsInCronTasks verifies that the background cron tasks correctly handle runs/jobs
// created before migration v331 (legacy data with LatestAttemptID=0 and jobs with RunAttemptID=0).
func TestLegacyRunsInCronTasks(t *testing.T) {
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-legacy-cron", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(httpContext)(t)
// Far-past timestamp so the queries match regardless of the configured timeouts.
oldTS := timeutil.TimeStamp(time.Now().Add(-30 * 24 * time.Hour).Unix())
// insertLegacyRunJob inserts a run + job without an ActionRunAttempt record, simulating data created before migration v331 (LatestAttemptID=0, job.RunAttemptID=0, job.AttemptJobID=0).
insertLegacyRunJob := func(t *testing.T, index int64, runStatus, jobStatus actions_model.Status) (*actions_model.ActionRun, *actions_model.ActionRunJob) {
t.Helper()
run := &actions_model.ActionRun{
Title: fmt.Sprintf("legacy run %d", index),
RepoID: repo.ID,
OwnerID: repo.OwnerID,
WorkflowID: fmt.Sprintf("legacy-%d.yml", index),
Index: index,
TriggerUserID: user2.ID,
Ref: "refs/heads/" + repo.DefaultBranch,
CommitSHA: "0000000000000000000000000000000000000000",
Event: "workflow_dispatch",
TriggerEvent: "workflow_dispatch",
EventPayload: "{}",
Status: runStatus,
}
require.NoError(t, db.Insert(t.Context(), run))
job := &actions_model.ActionRunJob{
RunID: run.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: run.CommitSHA,
Name: "legacy-job",
Attempt: 1,
JobID: "legacy-job",
RunsOn: []string{"ubuntu-latest"},
Status: jobStatus,
RunAttemptID: 0,
AttemptJobID: 0,
}
require.NoError(t, db.Insert(t.Context(), job))
// backfill timestamps so the cron task queries can match them.
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_run SET created=?, updated=? WHERE id=?", int64(oldTS), int64(oldTS), run.ID)
require.NoError(t, err)
_, err = db.GetEngine(t.Context()).Exec("UPDATE action_run_job SET created=?, updated=? WHERE id=?", int64(oldTS), int64(oldTS), job.ID)
require.NoError(t, err)
run.Created, run.Updated = oldTS, oldTS
job.Created, job.Updated = oldTS, oldTS
return run, job
}
t.Run("StopZombieTasks", func(t *testing.T) {
run, job := insertLegacyRunJob(t, 10, actions_model.StatusRunning, actions_model.StatusRunning)
task := &actions_model.ActionTask{
JobID: job.ID,
Attempt: 1,
Status: actions_model.StatusRunning,
Started: oldTS,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: run.CommitSHA,
}
task.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), task))
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_task SET updated=? WHERE id=?", int64(oldTS), task.ID)
require.NoError(t, err)
job.TaskID = task.ID
_, err = db.GetEngine(t.Context()).ID(job.ID).Cols("task_id").Update(job)
require.NoError(t, err)
require.NoError(t, actions_service.StopZombieTasks(t.Context()))
gotTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID})
assert.Equal(t, actions_model.StatusFailure, gotTask.Status)
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
assert.Equal(t, actions_model.StatusFailure, gotJob.Status)
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusFailure, gotRun.Status)
})
t.Run("StopEndlessTasks", func(t *testing.T) {
run, job := insertLegacyRunJob(t, 20, actions_model.StatusRunning, actions_model.StatusRunning)
task := &actions_model.ActionTask{
JobID: job.ID,
Attempt: 1,
Status: actions_model.StatusRunning,
Started: oldTS,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: run.CommitSHA,
}
task.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), task))
job.TaskID = task.ID
_, err := db.GetEngine(t.Context()).ID(job.ID).Cols("task_id").Update(job)
require.NoError(t, err)
require.NoError(t, actions_service.StopEndlessTasks(t.Context()))
gotTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.ID})
assert.Equal(t, actions_model.StatusFailure, gotTask.Status)
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
assert.Equal(t, actions_model.StatusFailure, gotJob.Status)
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusFailure, gotRun.Status)
})
t.Run("CancelAbandonedJobs", func(t *testing.T) {
run, job := insertLegacyRunJob(t, 30, actions_model.StatusWaiting, actions_model.StatusWaiting)
require.NoError(t, actions_service.CancelAbandonedJobs(t.Context()))
gotJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
assert.Equal(t, actions_model.StatusCancelled, gotJob.Status)
gotRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusCancelled, gotRun.Status)
})
t.Run("Cleanup", func(t *testing.T) {
run, _ := insertLegacyRunJob(t, 40, actions_model.StatusSuccess, actions_model.StatusSuccess)
expiredArtifact := &actions_model.ActionArtifact{
RunID: run.ID,
RunAttemptID: 0, // legacy artifact
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: run.CommitSHA,
StoragePath: fmt.Sprintf("artifacts/legacy-expired-%d.zip", run.ID),
FileSize: 1,
FileCompressedSize: 1,
ContentEncodingOrType: actions_model.ContentTypeZip,
ArtifactPath: "legacy-expired.zip",
ArtifactName: "legacy-expired",
Status: actions_model.ArtifactStatusUploadConfirmed,
ExpiredUnix: oldTS,
}
require.NoError(t, db.Insert(t.Context(), expiredArtifact))
require.NoError(t, actions_service.Cleanup(t.Context()))
gotArtifact := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: expiredArtifact.ID})
assert.Equal(t, actions_model.ArtifactStatusExpired, gotArtifact.Status)
})
})
}
@@ -12,19 +12,19 @@ import (
"strconv"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
org_model "code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo"
unit_model "code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/lfs"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/tests"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
org_model "gitea.dev/models/organization"
"gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
unit_model "gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/lfs"
"gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -137,7 +137,7 @@ func TestActionsJobTokenPermissiveAccess(t *testing.T) {
require.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoActionsUnit))
// prepare task and its token
require.NoError(t, task.GenerateToken())
task.GenerateAndFillToken()
task.Status = actions_model.StatusRunning
task.IsForkPullRequest = tt.isFork
err := actions_model.UpdateTask(t.Context(), task, "token_hash", "token_salt", "token_last_eight", "status", "is_fork_pull_request")
@@ -208,8 +208,7 @@ func TestActionsCrossRepoAccess(t *testing.T) {
AutoInit: true,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var repo structs.Repository
DecodeJSON(t, resp, &repo)
repo := DecodeJSON(t, resp, &structs.Repository{})
return repo.ID
}
@@ -309,7 +308,7 @@ func TestActionsJobTokenPermissionsWriteIssue(t *testing.T) {
repoActionsCfg.MaxTokenPermissions = nil
require.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoActionsUnit))
require.NoError(t, task.GenerateToken())
task.GenerateAndFillToken()
task.Status = actions_model.StatusRunning
require.NoError(t, actions_model.UpdateTask(t.Context(), task, "token_hash", "token_salt", "token_last_eight", "status"))
@@ -359,7 +358,7 @@ func createActionTask(t *testing.T, repoID int64, isFork bool) *actions_model.Ac
Status: actions_model.StatusRunning,
IsForkPullRequest: isFork,
}
require.NoError(t, task.GenerateToken())
task.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), task))
return task
}
@@ -0,0 +1,68 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/tests"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionsListFilters(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
session := loginUser(t, user5.Name)
actionsURL := fmt.Sprintf("/%s/%s/actions", user5.Name, repo.Name)
t.Run("BranchDropdownListsBranches", func(t *testing.T) {
req := NewRequest(t, "GET", actionsURL)
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
var labels []string
htmlDoc.doc.Find(`[data-test-id="filter-branch"] .menu a.item`).Each(func(_ int, a *goquery.Selection) {
labels = append(labels, strings.TrimSpace(a.Text()))
})
assert.Contains(t, labels, "master")
})
t.Run("FilterByBranch", func(t *testing.T) {
req := NewRequest(t, "GET", actionsURL+"?branch=master")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
refs := htmlDoc.doc.Find(".run-list .run-list-ref")
assert.Positive(t, refs.Length(), "filtered run list should not be empty")
refs.Each(func(_ int, sel *goquery.Selection) {
assert.Equal(t, "master", strings.TrimSpace(sel.Text()))
})
})
t.Run("PaginationPreservesFilters", func(t *testing.T) {
req := NewRequest(t, "GET", actionsURL+"?branch=master&limit=1")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
pageLinks := htmlDoc.doc.Find(".pagination a[href]")
assert.Positive(t, pageLinks.Length(), "pagination should be rendered")
pageLinks.Each(func(_ int, a *goquery.Selection) {
u, err := url.Parse(a.AttrOr("href", ""))
require.NoError(t, err)
assert.Equal(t, "master", u.Query().Get("branch"), "pagination link must preserve branch filter")
})
})
}
@@ -9,16 +9,16 @@ import (
"os"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/dbfs"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
actions_module "code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/storage"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/dbfs"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/storage"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -73,5 +73,19 @@ jobs:
_, err = dbfs.Open(t.Context(), actions_module.DBFSPrefix+freshTask.LogFilename)
assert.ErrorIs(t, err, os.ErrNotExist, "DBFS row must be cleaned up after TransferLogs")
// The runner re-sends its final UpdateLog when the response was lost.
// A sealed log must ack the re-send and still reject new appended rows.
t.Run("re-sent finalize is idempotent", func(t *testing.T) {
finalize := &runnerv1.UpdateLogRequest{TaskId: task.Id, Index: 0, Rows: nil, NoMore: true}
resp, err := runner.client.runnerServiceClient.UpdateLog(t.Context(), connect.NewRequest(finalize))
require.NoError(t, err)
assert.EqualValues(t, 0, resp.Msg.AckIndex)
_, err = runner.client.runnerServiceClient.UpdateLog(t.Context(), connect.NewRequest(&runnerv1.UpdateLogRequest{
TaskId: task.Id, Index: 0, Rows: []*runnerv1.LogRow{{Content: "late"}}, NoMore: true,
}))
require.Error(t, err, "appending rows past the seal must be rejected")
})
})
}
@@ -11,15 +11,16 @@ import (
"testing"
"time"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/test"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
"gitea.dev/modules/test"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -214,5 +215,95 @@ jobs:
resetFunc()
})
}
t.Run("DownloadRerunTaskLogs", func(t *testing.T) {
treePath := ".gitea/workflows/download-rerun-logs.yml"
fileContent := `name: download-rerun-logs
on:
push:
paths:
- '.gitea/workflows/download-rerun-logs.yml'
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo job1
job2:
runs-on: ubuntu-latest
needs: [job1]
steps:
- run: echo job2
`
// create the workflow file
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+treePath, fileContent)
createWorkflowFile(t, token, user2.Name, repo.Name, treePath, opts)
// first run
job1Task1 := runner.fetchTask(t)
_, job1, _ := getTaskAndJobAndRunByTaskID(t, job1Task1.Id)
runner.execTask(t, job1Task1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
logRows: []*runnerv1.LogRow{
{
Time: timestamppb.New(now.Add(1 * time.Second)),
Content: "job1 first run",
},
},
})
job2Task1 := runner.fetchTask(t)
_, job2, run := getTaskAndJobAndRunByTaskID(t, job2Task1.Id)
runner.execTask(t, job2Task1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
logRows: []*runnerv1.LogRow{
{
Time: timestamppb.New(now.Add(1 * time.Second)),
Content: "job2 first run",
},
},
})
// check job1 log
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job1.ID)).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "job1 first run")
// check job2 log
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job2.ID)).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "job2 first run")
// only rerun job2
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job2.ID))
session.MakeRequest(t, req, http.StatusOK)
job2TaskRerun := runner.fetchTask(t)
runner.execTask(t, job2TaskRerun, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
logRows: []*runnerv1.LogRow{
{
Time: timestamppb.New(now.Add(1 * time.Second)),
Content: "job2 rerun",
},
},
})
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
job1Rerun := getLatestAttemptJobByTemplateJobID(t, run.ID, job1.ID)
assert.Equal(t, run.LatestAttemptID, job1Rerun.RunAttemptID)
job2Rerun := getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
assert.Equal(t, run.LatestAttemptID, job2Rerun.RunAttemptID)
// check job1 rerun log
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job1Rerun.ID)).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "job1 first run") // should return the log of first run because job1 didn't rerun
// check job2 rerun log
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/logs", user2.Name, repo.Name, run.ID, job2Rerun.ID)).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "job2 rerun")
})
})
}
@@ -7,18 +7,33 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"time"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
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/jobparser"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
actions_web "gitea.dev/routers/web/repo/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionsRerun(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
userAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
sessionAdmin := loginUser(t, userAdmin.Name)
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
@@ -54,6 +69,7 @@ jobs:
// fetch and exec job1
job1Task := runner.fetchTask(t)
assert.Equal(t, "1", job1Task.Context.GetFields()["run_attempt"].GetStringValue())
_, job1, run := getTaskAndJobAndRunByTaskID(t, job1Task.Id)
runner.execTask(t, job1Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
@@ -67,45 +83,475 @@ jobs:
runner.execTask(t, job2Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
assert.EqualValues(t, 1, getRunLatestAttemptNum(t, run.ID))
// RERUN-1: rerun the run
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
session.MakeRequest(t, req, http.StatusOK)
sessionAdmin.MakeRequest(t, req, http.StatusOK) // triggered by admin user
// fetch and exec job1
job1TaskR1 := runner.fetchTask(t)
assert.Equal(t, "2", job1TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
_, job1R1, _ := getTaskAndJobAndRunByTaskID(t, job1TaskR1.Id)
assert.Equal(t, job1.AttemptJobID, job1R1.AttemptJobID)
runner.execTask(t, job1TaskR1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
// fetch and exec job2
job2TaskR1 := runner.fetchTask(t)
assert.Equal(t, "2", job2TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
_, job2R1, _ := getTaskAndJobAndRunByTaskID(t, job2TaskR1.Id)
assert.Equal(t, job2.AttemptJobID, job2R1.AttemptJobID)
runner.execTask(t, job2TaskR1, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
assert.EqualValues(t, 2, getRunLatestAttemptNum(t, run.ID))
// RERUN-2: rerun job1
job1 = getLatestAttemptJobByTemplateJobID(t, run.ID, job1.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job1.ID))
session.MakeRequest(t, req, http.StatusOK)
// job2 needs job1, so rerunning job1 will also rerun job2
// fetch and exec job1
job1TaskR2 := runner.fetchTask(t)
assert.Equal(t, "3", job1TaskR2.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job1TaskR2, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
// fetch and exec job2
job2TaskR2 := runner.fetchTask(t)
assert.Equal(t, "3", job2TaskR2.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job2TaskR2, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
assert.EqualValues(t, 3, getRunLatestAttemptNum(t, run.ID))
// RERUN-3: rerun job2
job2 = getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", user2.Name, repo.Name, run.ID, job2.ID))
session.MakeRequest(t, req, http.StatusOK)
// only job2 will rerun
// fetch and exec job2
job2TaskR3 := runner.fetchTask(t)
assert.Equal(t, "4", job2TaskR3.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job2TaskR3, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
runner.fetchNoTask(t)
assert.EqualValues(t, 4, getRunLatestAttemptNum(t, run.ID))
runLatestAttempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
job2LatestAttempt := getLatestAttemptJobByTemplateJobID(t, run.ID, job2.ID)
assert.Equal(t, runLatestAttempt.LatestAttemptID, job2LatestAttempt.RunAttemptID)
t.Run("RerunFailedWithNoFailedJobs", func(t *testing.T) {
// The run is fully successful, so an empty failed-job list must be rejected rather than fall
// through to re-running the whole run.
before := getRunLatestAttemptNum(t, run.ID)
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun-failed", user2.Name, repo.Name, run.ID))
resp := session.MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "no failed jobs")
assert.Equal(t, before, getRunLatestAttemptNum(t, run.ID))
runner.fetchNoTask(t) // no new tasks were scheduled
})
t.Run("RerunFailedWithNoFailedJobsAPI", func(t *testing.T) {
// Same rejection on the API route: an empty failed-job list must not fall through to a full re-run.
before := getRunLatestAttemptNum(t, run.ID)
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/rerun-failed-jobs", user2.Name, repo.Name, run.ID)).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "no failed jobs")
assert.Equal(t, before, getRunLatestAttemptNum(t, run.ID))
runner.fetchNoTask(t)
})
t.Run("AttemptAPI", func(t *testing.T) {
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/2", user2.Name, repo.Name, run.ID)).
AddTokenAuth(token)
attemptResp := MakeRequest(t, req, http.StatusOK)
apiAttempt := DecodeJSON(t, attemptResp, &api.ActionWorkflowRun{})
assert.Equal(t, run.ID, apiAttempt.ID)
assert.EqualValues(t, 2, apiAttempt.RunAttempt)
assert.Equal(t, "completed", apiAttempt.Status)
assert.Equal(t, "success", apiAttempt.Conclusion)
assert.NotNil(t, apiAttempt.PreviousAttemptURL)
assert.True(t, strings.HasSuffix(*apiAttempt.PreviousAttemptURL, fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/1", user2.Name, repo.Name, run.ID)))
assert.Equal(t, user2.Name, apiAttempt.Actor.UserName)
assert.Equal(t, userAdmin.Name, apiAttempt.TriggerActor.UserName)
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/attempts/2/jobs", user2.Name, repo.Name, run.ID)).
AddTokenAuth(token)
attemptJobsResp := MakeRequest(t, req, http.StatusOK)
apiAttemptJobs := DecodeJSON(t, attemptJobsResp, &api.ActionWorkflowJobsResponse{})
assert.Len(t, apiAttemptJobs.Entries, 2)
assert.ElementsMatch(t, []int64{job1R1.ID, job2R1.ID}, []int64{apiAttemptJobs.Entries[0].ID, apiAttemptJobs.Entries[1].ID})
})
t.Run("MaxRerunAttempts", func(t *testing.T) {
// The run has 4 attempts after the previous reruns. Lower the cap to 4 to hit the limit.
defer test.MockVariableValue(&setting.Actions.MaxRerunAttempts, int64(4))()
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
resp := session.MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "workflow run has reached the maximum")
assert.EqualValues(t, 4, getRunLatestAttemptNum(t, run.ID))
// Raising the cap lets rerun proceed again.
defer test.MockVariableValue(&setting.Actions.MaxRerunAttempts, int64(5))()
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, run.ID))
session.MakeRequest(t, req, http.StatusOK)
// fetch and exec job1
job1TaskR4 := runner.fetchTask(t)
assert.Equal(t, "5", job1TaskR4.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job1TaskR4, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
job2TaskR4 := runner.fetchTask(t)
assert.Equal(t, "5", job2TaskR4.Context.GetFields()["run_attempt"].GetStringValue())
runner.execTask(t, job2TaskR4, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
assert.EqualValues(t, 5, getRunLatestAttemptNum(t, run.ID))
})
})
}
func TestActionsRerunLegacyNoAttemptRun(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-rerun-legacy", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(httpContext)(t)
runner := newMockRunner()
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
wfTreePath := ".gitea/workflows/actions-rerun-legacy.yml"
wfFileContent := `name: actions-rerun-legacy
on:
workflow_dispatch:
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo 'job1'
job2:
runs-on: ubuntu-latest
needs: [job1]
steps:
- run: echo 'job2'
`
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+wfTreePath, wfFileContent)
fileResp := createWorkflowFile(t, token, user2.Name, repo.Name, wfTreePath, opts)
require.NotNil(t, fileResp)
// Start preparing legacy data
payloads := mustParseSingleWorkflowPayloads(t, wfFileContent)
now := timeutil.TimeStamp(time.Now().Unix())
started := now - 20
stopped := now - 10
legacyRun := &actions_model.ActionRun{
Title: "legacy rerun test",
RepoID: repo.ID,
OwnerID: repo.OwnerID,
WorkflowID: "actions-rerun-legacy.yml",
Index: 1,
TriggerUserID: user2.ID,
Ref: "refs/heads/" + repo.DefaultBranch,
CommitSHA: fileResp.Commit.SHA,
Event: "workflow_dispatch",
TriggerEvent: "workflow_dispatch",
EventPayload: "{}",
Status: actions_model.StatusSuccess,
Started: started,
Stopped: stopped,
Created: started - 5,
Updated: stopped,
}
require.NoError(t, db.Insert(t.Context(), legacyRun))
// xorm does not update "created"-tagged fields via ORM methods; use raw SQL to backfill historical timestamps.
_, err := db.GetEngine(t.Context()).Exec("UPDATE action_run SET created=?, updated=? WHERE id=?", int64(started-5), int64(stopped), legacyRun.ID)
require.NoError(t, err)
legacyRun.Created = started - 5
legacyRun.Updated = stopped
legacyJob1 := &actions_model.ActionRunJob{
RunID: legacyRun.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
Name: payloads["job1"].name,
Attempt: 1,
WorkflowPayload: payloads["job1"].payload,
JobID: "job1",
Needs: payloads["job1"].needs,
RunsOn: payloads["job1"].runsOn,
Status: actions_model.StatusSuccess,
RunAttemptID: 0,
AttemptJobID: 0,
Started: started,
Stopped: stopped,
IsForkPullRequest: false,
}
legacyJob2 := &actions_model.ActionRunJob{
RunID: legacyRun.ID,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
Name: payloads["job2"].name,
Attempt: 1,
WorkflowPayload: payloads["job2"].payload,
JobID: "job2",
Needs: payloads["job2"].needs,
RunsOn: payloads["job2"].runsOn,
Status: actions_model.StatusSuccess,
RunAttemptID: 0,
AttemptJobID: 0,
Started: started,
Stopped: stopped,
IsForkPullRequest: false,
}
require.NoError(t, db.Insert(t.Context(), legacyJob1, legacyJob2))
legacyTask1 := &actions_model.ActionTask{
JobID: legacyJob1.ID,
Attempt: 1,
Status: actions_model.StatusSuccess,
Started: started,
Stopped: stopped,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
IsForkPullRequest: false,
}
legacyTask1.GenerateAndFillToken()
legacyTask2 := &actions_model.ActionTask{
JobID: legacyJob2.ID,
Attempt: 1,
Status: actions_model.StatusSuccess,
Started: started,
Stopped: stopped,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
IsForkPullRequest: false,
}
legacyTask2.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), legacyTask1, legacyTask2))
legacyJob1.TaskID = legacyTask1.ID
legacyJob2.TaskID = legacyTask2.ID
_, err = db.GetEngine(t.Context()).ID(legacyJob1.ID).Cols("task_id").Update(legacyJob1)
require.NoError(t, err)
_, err = db.GetEngine(t.Context()).ID(legacyJob2.ID).Cols("task_id").Update(legacyJob2)
require.NoError(t, err)
legacyArtifact := &actions_model.ActionArtifact{
RunID: legacyRun.ID,
RunAttemptID: 0,
RepoID: repo.ID,
OwnerID: repo.OwnerID,
CommitSHA: legacyRun.CommitSHA,
StoragePath: "artifacts/legacy-artifact.zip",
FileSize: 123,
FileCompressedSize: 123,
ContentEncodingOrType: actions_model.ContentTypeZip,
ArtifactPath: "legacy-artifact.zip",
ArtifactName: "legacy-artifact",
Status: actions_model.ArtifactStatusUploadConfirmed,
ExpiredUnix: now + timeutil.Day,
}
require.NoError(t, db.Insert(t.Context(), legacyArtifact))
// Done preparing legacy data
// assert the web view for the legacy run before rerun
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, legacyRun.ID))
legacyResp := session.MakeRequest(t, req, http.StatusOK)
legacyView := DecodeJSON(t, legacyResp, &actions_web.ViewResponse{})
// legacy run has no attempt records, so RunAttempt is 0 and Attempts list is empty
assert.EqualValues(t, 0, legacyView.State.Run.RunAttempt)
assert.Empty(t, legacyView.State.Run.Attempts)
assert.Equal(t, "success", legacyView.State.Run.Status)
assert.True(t, legacyView.State.Run.Done)
// isLatestAttempt=true, done=true: can rerun but not cancel
assert.False(t, legacyView.State.Run.CanCancel)
assert.False(t, legacyView.State.Run.CanApprove)
assert.True(t, legacyView.State.Run.CanRerun)
assert.False(t, legacyView.State.Run.CanRerunFailed) // all jobs succeeded
assert.True(t, legacyView.State.Run.CanDeleteArtifact)
if assert.Len(t, legacyView.State.Run.Jobs, 2) {
assert.Equal(t, legacyJob1.ID, legacyView.State.Run.Jobs[0].ID)
assert.Equal(t, legacyJob2.ID, legacyView.State.Run.Jobs[1].ID)
}
if assert.Len(t, legacyView.Artifacts, 1) {
assert.Equal(t, legacyArtifact.ArtifactName, legacyView.Artifacts[0].Name)
assert.Equal(t, "completed", legacyView.Artifacts[0].Status)
}
// rerun the legacy run
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user2.Name, repo.Name, legacyRun.ID))
session.MakeRequest(t, req, http.StatusOK)
runAfterRerun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
assert.EqualValues(t, 2, getRunLatestAttemptNum(t, legacyRun.ID))
jobsAfterRerun, err := actions_model.GetRunJobsByRunAndAttemptID(t.Context(), legacyRun.ID, runAfterRerun.LatestAttemptID)
require.NoError(t, err)
require.Len(t, jobsAfterRerun, 2)
rerunJobsByJobID := map[string]*actions_model.ActionRunJob{}
for _, job := range jobsAfterRerun {
rerunJobsByJobID[job.JobID] = job
}
require.Contains(t, rerunJobsByJobID, "job1")
require.Contains(t, rerunJobsByJobID, "job2")
assert.Equal(t, actions_model.StatusWaiting, rerunJobsByJobID["job1"].Status)
assert.Equal(t, actions_model.StatusBlocked, rerunJobsByJobID["job2"].Status)
// fetch job1 rerun task
job1TaskR1 := runner.fetchTask(t)
assert.Equal(t, "2", job1TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
rerunJob1Task, rerunJob1, rerunRun := getTaskAndJobAndRunByTaskID(t, job1TaskR1.Id)
assert.Equal(t, legacyRun.ID, rerunRun.ID)
assert.Equal(t, rerunJob1.RunAttemptID, rerunRun.LatestAttemptID)
runner.execTask(t, job1TaskR1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
// fetch job2 rerun task
job2TaskR1 := runner.fetchTask(t)
assert.Equal(t, "2", job2TaskR1.Context.GetFields()["run_attempt"].GetStringValue())
rerunJob2Task, rerunJob2, _ := getTaskAndJobAndRunByTaskID(t, job2TaskR1.Id)
runner.execTask(t, job2TaskR1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
runner.fetchNoTask(t)
// query the 2 attempts
runAfterRerun = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: legacyRun.ID})
attempt1, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(t.Context(), legacyRun.ID, 1)
require.NoError(t, err)
assert.Equal(t, legacyRun.Created, attempt1.Created)
assert.Equal(t, legacyRun.Started, attempt1.Started)
assert.Equal(t, legacyRun.Stopped, attempt1.Stopped)
attempt2, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(t.Context(), legacyRun.ID, 2)
require.NoError(t, err)
assert.Equal(t, attempt2.ID, runAfterRerun.LatestAttemptID)
assert.Equal(t, runAfterRerun.Created, attempt1.Created)
assert.Equal(t, runAfterRerun.Started, attempt2.Started)
assert.Equal(t, runAfterRerun.Stopped, attempt2.Stopped)
// assert legacy jobs
legacyJob1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob1.ID})
legacyJob2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: legacyJob2.ID})
assert.Equal(t, attempt1.ID, legacyJob1.RunAttemptID)
assert.Equal(t, attempt1.ID, legacyJob2.RunAttemptID)
assert.EqualValues(t, 1, legacyJob1.Attempt)
assert.EqualValues(t, 1, legacyJob2.Attempt)
assert.EqualValues(t, 1, legacyJob1.AttemptJobID)
assert.EqualValues(t, 2, legacyJob2.AttemptJobID)
legacyTask1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: legacyTask1.ID})
legacyTask2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: legacyTask2.ID})
assert.EqualValues(t, 1, legacyTask1.Attempt)
assert.EqualValues(t, 1, legacyTask2.Attempt)
// assert legacy artifacts
legacyArtifact = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionArtifact{ID: legacyArtifact.ID})
assert.Equal(t, attempt1.ID, legacyArtifact.RunAttemptID)
// assert jobs of the latest rerun
rerunJob1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: rerunJob1.ID})
rerunJob2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: rerunJob2.ID})
assert.Equal(t, attempt2.ID, rerunJob1.RunAttemptID)
assert.Equal(t, attempt2.ID, rerunJob2.RunAttemptID)
assert.Equal(t, legacyJob1.AttemptJobID, rerunJob1.AttemptJobID)
assert.Equal(t, legacyJob2.AttemptJobID, rerunJob2.AttemptJobID)
assert.EqualValues(t, 2, rerunJob1Task.Attempt)
assert.EqualValues(t, 2, rerunJob2Task.Attempt)
// assert the web view for the original attempt
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/attempts/1", user2.Name, repo.Name, legacyRun.ID))
attempt1Resp := session.MakeRequest(t, req, http.StatusOK)
attempt1View := DecodeJSON(t, attempt1Resp, &actions_web.ViewResponse{})
assert.EqualValues(t, 1, attempt1View.State.Run.RunAttempt)
if assert.Len(t, attempt1View.State.Run.Attempts, 2) {
// attempts ordered by attempt DESC: index 0 = attempt #2 (latest), index 1 = attempt #1 (current)
assert.False(t, attempt1View.State.Run.Attempts[0].Current)
assert.True(t, attempt1View.State.Run.Attempts[0].Latest)
assert.True(t, attempt1View.State.Run.Attempts[1].Current)
assert.False(t, attempt1View.State.Run.Attempts[1].Latest)
}
// isLatestAttempt=false: all write operations disabled
assert.False(t, attempt1View.State.Run.CanCancel)
assert.False(t, attempt1View.State.Run.CanApprove)
assert.False(t, attempt1View.State.Run.CanRerun)
assert.False(t, attempt1View.State.Run.CanRerunFailed)
assert.True(t, attempt1View.State.Run.CanDeleteArtifact)
assert.Equal(t, legacyJob1.ID, attempt1View.State.Run.Jobs[0].ID)
assert.Equal(t, legacyJob2.ID, attempt1View.State.Run.Jobs[1].ID)
if assert.Len(t, attempt1View.Artifacts, 1) {
assert.Equal(t, attempt1View.Artifacts[0].Name, legacyArtifact.ArtifactName)
}
// assert the web view for the latest attempt
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, legacyRun.ID))
attempt2Resp := session.MakeRequest(t, req, http.StatusOK)
attempt2View := DecodeJSON(t, attempt2Resp, &actions_web.ViewResponse{})
assert.EqualValues(t, 2, attempt2View.State.Run.RunAttempt)
if assert.Len(t, attempt2View.State.Run.Attempts, 2) {
// attempts ordered by attempt DESC: index 0 = attempt #2 (latest, current), index 1 = attempt #1
assert.True(t, attempt2View.State.Run.Attempts[0].Current)
assert.True(t, attempt2View.State.Run.Attempts[0].Latest)
assert.False(t, attempt2View.State.Run.Attempts[1].Current)
assert.False(t, attempt2View.State.Run.Attempts[1].Latest)
}
// isLatestAttempt=true, done=true: can rerun but not cancel
assert.False(t, attempt2View.State.Run.CanCancel)
assert.False(t, attempt2View.State.Run.CanApprove)
assert.True(t, attempt2View.State.Run.CanRerun)
assert.False(t, attempt2View.State.Run.CanRerunFailed) // all jobs succeeded
assert.True(t, attempt2View.State.Run.CanDeleteArtifact)
assert.Equal(t, rerunJob1.ID, attempt2View.State.Run.Jobs[0].ID)
assert.Equal(t, rerunJob2.ID, attempt2View.State.Run.Jobs[1].ID)
assert.Empty(t, attempt2View.Artifacts)
})
}
type workflowJobPayload struct {
name string
payload []byte
needs []string
runsOn []string
}
func mustParseSingleWorkflowPayloads(t *testing.T, workflowContent string) map[string]workflowJobPayload {
t.Helper()
workflows, err := jobparser.Parse([]byte(workflowContent))
require.NoError(t, err)
payloads := make(map[string]workflowJobPayload, len(workflows))
for _, workflow := range workflows {
id, job := workflow.Job()
needs := job.Needs()
require.NoError(t, workflow.SetJob(id, job.EraseNeeds()))
payload, err := workflow.Marshal()
require.NoError(t, err)
payloads[id] = workflowJobPayload{
name: job.Name,
payload: payload,
needs: needs,
runsOn: job.RunsOn(),
}
}
return payloads
}
func getRunLatestAttemptNum(t *testing.T, runID int64) int64 {
t.Helper()
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
attempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: run.LatestAttemptID})
return attempt.Attempt
}
@@ -0,0 +1,985 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"testing"
"time"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/json"
"gitea.dev/modules/queue"
api "gitea.dev/modules/structs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionsReusableWorkflow(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user2Session := loginUser(t, user2.Name)
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
user4Session := loginUser(t, user4.Name)
user4Token := getTokenForLoggedInUser(t, user4Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
t.Run("Same-repo reusable workflow", func(t *testing.T) {
apiRepo := createActionsTestRepo(t, user2Token, "workflow-call-test", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
defaultRunner := newMockRunner()
defaultRunner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-default-runner", []string{"ubuntu-latest"}, false)
customRunner := newMockRunner()
customRunner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-custom-runner", []string{"custom-os"}, false)
// add a variable for test
req := NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables/myvar", repo.OwnerName, repo.Name), &api.CreateVariableOption{
Value: "abcdef",
}).
AddTokenAuth(user2Token)
MakeRequest(t, req, http.StatusCreated)
// add a secret for test
req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/mysecret", repo.OwnerName, repo.Name), api.CreateOrUpdateSecretOption{
Data: "secRET-t0Ken",
}).AddTokenAuth(user2Token)
MakeRequest(t, req, http.StatusCreated)
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/reusable1.yaml",
`name: Reusable1
on:
workflow_call:
inputs:
str_input:
type: string
num_input:
type: number
bool_input:
type: boolean
parent_var:
type: string
needs_out:
type: string
secrets:
PARENT_TOKEN:
outputs:
r1_out:
value: ${{ jobs.reusable1_job2.outputs.r1j2_out }}
jobs:
reusable1_job1:
runs-on: ubuntu-latest
steps:
- run: echo 'reusable1_job1'
reusable1_job2:
needs: [reusable1_job1]
outputs:
r1j2_out: ${{ steps.gen_r1j2_output.outputs.out }}
runs-on: custom-os
steps:
- id: gen_r1j2_output
run: |
echo "out=r1j2_out_data" >> "$GITHUB_OUTPUT"
reusable1_job3:
needs: [reusable1_job2]
uses: ./.gitea/workflows/reusable2.yaml
with:
msg: ${{ inputs.str_input }}
`)
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/reusable2.yaml",
`name: Reusable2
on:
workflow_call:
inputs:
msg:
type: string
jobs:
reusable2_job1:
runs-on: ubuntu-latest
steps:
- run: echo ${{ inputs.msg }}
`)
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
`name: Caller
on:
push:
paths:
- '.gitea/workflows/caller.yaml'
jobs:
caller_job1:
runs-on: ubuntu-latest
outputs:
prepared: ${{ steps.gen_output.outputs.pd }}
steps:
- id: gen_output
run: |
echo "pd=prepared_data" >> "$GITHUB_OUTPUT"
caller_job2:
needs: [caller_job1]
uses: './.gitea/workflows/reusable1.yaml'
with:
str_input: 'from_caller_job2'
num_input: ${{ 2.3e2 }}
bool_input: ${{ gitea.event_name == 'push' }}
parent_var: ${{ vars.myvar }}
needs_out: ${{ needs.caller_job1.outputs.prepared }}
secrets:
PARENT_TOKEN: ${{ secrets.mysecret }}
caller_job3:
needs: [caller_job2]
runs-on: ubuntu-latest
steps:
- run: |
echo ${{ needs.caller_job1.outputs.r1_out }}
`)
var (
runID int64
callerJob2ID, callerJob2AttemptJobID int64
callerJob3AttemptJobID int64
r1Job2ID, r1Job2AttemptJobID int64
r1Job3ID, r1Job3AttemptJobID int64
r2Job1AttemptJobID int64
)
t.Run("Check initialized jobs", func(t *testing.T) {
// run
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID})
runID = run.ID
// caller_job1
assert.Equal(t, 3, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID}))
callerJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "caller_job1"})
assert.Equal(t, actions_model.StatusWaiting, callerJob1.Status)
assert.False(t, callerJob1.IsReusableCaller)
// caller_job2
callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "caller_job2"})
callerJob2ID = callerJob2.ID
callerJob2AttemptJobID = callerJob2.AttemptJobID
assert.Equal(t, actions_model.StatusBlocked, callerJob2.Status)
assert.True(t, callerJob2.IsReusableCaller)
// caller_job3
callerJob3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "caller_job3"})
callerJob3AttemptJobID = callerJob3.AttemptJobID
assert.Equal(t, actions_model.StatusBlocked, callerJob3.Status)
assert.False(t, callerJob3.IsReusableCaller)
})
t.Run("First run", func(t *testing.T) {
callerJob1Task := defaultRunner.fetchTask(t) // for caller_job1
_, callerJob1, _ := getTaskAndJobAndRunByTaskID(t, callerJob1Task.Id)
assert.Equal(t, "caller_job1", callerJob1.JobID)
defaultRunner.fetchNoTask(t)
defaultRunner.execTask(t, callerJob1Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"prepared": "prepared_data",
},
})
r1Job1Task := defaultRunner.fetchTask(t) // for reusable1_job1
_, r1Job1, _ := getTaskAndJobAndRunByTaskID(t, r1Job1Task.Id)
assert.Equal(t, "reusable1_job1", r1Job1.JobID)
assert.Equal(t, callerJob2ID, r1Job1.ParentJobID)
payload := getWorkflowCallPayloadFromTask(t, r1Job1Task)
if assert.Len(t, payload.Inputs, 5) {
assert.Equal(t, "from_caller_job2", payload.Inputs["str_input"])
assert.EqualValues(t, 230, payload.Inputs["num_input"])
assert.Equal(t, true, payload.Inputs["bool_input"])
assert.Equal(t, "abcdef", payload.Inputs["parent_var"])
assert.Equal(t, "prepared_data", payload.Inputs["needs_out"])
}
if assert.Len(t, r1Job1Task.Secrets, 3) {
assert.Contains(t, r1Job1Task.Secrets, "GITEA_TOKEN")
assert.Contains(t, r1Job1Task.Secrets, "GITHUB_TOKEN")
assert.Equal(t, "secRET-t0Ken", r1Job1Task.Secrets["PARENT_TOKEN"])
}
customRunner.fetchNoTask(t)
defaultRunner.execTask(t, r1Job1Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
// reusable1_job3 (a nested caller) needs reusable1_job2, so it stays Blocked until r1j2 succeeds.
r1Job3Pre := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable1_job3"})
assert.Equal(t, actions_model.StatusBlocked, r1Job3Pre.Status)
assert.False(t, r1Job3Pre.IsExpanded)
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable2_job1"}))
r1Job2Task := customRunner.fetchTask(t) // for reusable1_job2
_, r1Job2, _ := getTaskAndJobAndRunByTaskID(t, r1Job2Task.Id)
assert.Equal(t, "reusable1_job2", r1Job2.JobID)
r1Job2ID = r1Job2.ID
r1Job2AttemptJobID = r1Job2.AttemptJobID
if assert.Len(t, r1Job2Task.Needs, 1) {
assert.Contains(t, r1Job2Task.Needs, "reusable1_job1")
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, r1Job2Task.Needs["reusable1_job1"].Result)
}
customRunner.execTask(t, r1Job2Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"r1j2_out": "r1j2_out_data",
},
})
// Now reusable1_job3 expands and reusable2_job1 becomes runnable.
r1Job3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable1_job3"})
assert.True(t, r1Job3.IsReusableCaller)
assert.True(t, r1Job3.IsExpanded)
assert.Equal(t, callerJob2ID, r1Job3.ParentJobID)
r1Job3ID = r1Job3.ID
r1Job3AttemptJobID = r1Job3.AttemptJobID
r2Job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable2_job1"})
assert.Equal(t, r1Job3ID, r2Job1.ParentJobID)
r2Job1AttemptJobID = r2Job1.AttemptJobID
r2Job1Task := defaultRunner.fetchTask(t) // for reusable2_job1
_, fetchedR2Job1, _ := getTaskAndJobAndRunByTaskID(t, r2Job1Task.Id)
assert.Equal(t, "reusable2_job1", fetchedR2Job1.JobID)
assert.Equal(t, r1Job3ID, fetchedR2Job1.ParentJobID)
r2Job1Payload := getWorkflowCallPayloadFromTask(t, r2Job1Task)
if assert.Len(t, r2Job1Payload.Inputs, 1) {
assert.Equal(t, "from_caller_job2", r2Job1Payload.Inputs["msg"])
}
defaultRunner.execTask(t, r2Job1Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: callerJob2ID})
assert.Equal(t, actions_model.StatusSuccess, callerJob2.Status)
callerJob3Task := defaultRunner.fetchTask(t) // for caller_job3
_, callerJob3, _ := getTaskAndJobAndRunByTaskID(t, callerJob3Task.Id)
assert.Equal(t, "caller_job3", callerJob3.JobID)
if assert.Len(t, callerJob3Task.Needs, 1) {
assert.Contains(t, callerJob3Task.Needs, "caller_job2")
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, callerJob3Task.Needs["caller_job2"].Result)
if assert.Len(t, callerJob3Task.Needs["caller_job2"].Outputs, 1) {
assert.Equal(t, "r1j2_out_data", callerJob3Task.Needs["caller_job2"].Outputs["r1_out"])
}
}
defaultRunner.execTask(t, callerJob3Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
callerRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess, callerRun.Status)
})
t.Run("Rerun 'reusable1_job2'", func(t *testing.T) {
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, runID, r1Job2ID))
user2Session.MakeRequest(t, req, http.StatusOK)
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
attempt2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{RunID: runID, Attempt: 2})
assert.Equal(t, actions_model.StatusWaiting, attempt2.Status)
callerJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: callerJob2AttemptJobID})
assert.Equal(t, actions_model.StatusWaiting, callerJob2.Status)
callerJob3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: callerJob3AttemptJobID})
assert.Equal(t, actions_model.StatusBlocked, callerJob3.Status)
// reusable1_job3 needs reusable1_job2, so rerunning r1j2 pulls r1j3 (and its subtree) into the rerun set
r1Job3Attempt2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: r1Job3AttemptJobID})
assert.Equal(t, actions_model.StatusBlocked, r1Job3Attempt2.Status)
assert.True(t, r1Job3Attempt2.IsReusableCaller)
assert.False(t, r1Job3Attempt2.IsExpanded)
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, JobID: "reusable2_job1"}))
defaultRunner.fetchNoTask(t)
r1Job2Task := customRunner.fetchTask(t)
_, r1Job2, _ := getTaskAndJobAndRunByTaskID(t, r1Job2Task.Id)
assert.Equal(t, "reusable1_job2", r1Job2.JobID)
assert.Equal(t, callerJob2.ID, r1Job2.ParentJobID)
assert.Equal(t, r1Job2AttemptJobID, r1Job2.AttemptJobID)
assert.Equal(t, actions_model.StatusRunning, r1Job2.Status)
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusRunning, run.Status)
customRunner.execTask(t, r1Job2Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
outputs: map[string]string{
"r1j2_out": "r1j2_out_data_updated",
},
})
// r1j3 expands again. Its child reuses the AttemptJobID from attempt 1
r1Job3Attempt2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, AttemptJobID: r1Job3AttemptJobID})
assert.True(t, r1Job3Attempt2.IsExpanded)
r2Job1Attempt2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, JobID: "reusable2_job1"})
assert.Equal(t, r2Job1AttemptJobID, r2Job1Attempt2.AttemptJobID)
assert.Equal(t, r1Job3Attempt2.ID, r2Job1Attempt2.ParentJobID)
r2Job1Task := defaultRunner.fetchTask(t)
_, fetchedR2Job1, _ := getTaskAndJobAndRunByTaskID(t, r2Job1Task.Id)
assert.Equal(t, "reusable2_job1", fetchedR2Job1.JobID)
defaultRunner.execTask(t, r2Job1Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
callerJob2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: callerJob2.ID})
assert.Equal(t, actions_model.StatusSuccess, callerJob2.Status)
callerJob3Task := defaultRunner.fetchTask(t)
_, callerJob3, _ = getTaskAndJobAndRunByTaskID(t, callerJob3Task.Id)
assert.Equal(t, "caller_job3", callerJob3.JobID)
if assert.Len(t, callerJob3Task.Needs, 1) {
assert.Contains(t, callerJob3Task.Needs, "caller_job2")
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, callerJob3Task.Needs["caller_job2"].Result)
if assert.Len(t, callerJob3Task.Needs["caller_job2"].Outputs, 1) {
assert.Equal(t, "r1j2_out_data_updated", callerJob3Task.Needs["caller_job2"].Outputs["r1_out"])
}
}
defaultRunner.execTask(t, callerJob3Task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
attempt2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{RunID: runID, Attempt: 2})
assert.Equal(t, actions_model.StatusSuccess, attempt2.Status)
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess, run.Status)
})
})
t.Run("Cross-repo reusable workflow with collaborative owner", func(t *testing.T) {
// libRepo: private, owned by user2.
libAPIRepo := createActionsTestRepo(t, user2Token, "reusable-lib-private", true)
libRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: libAPIRepo.ID})
createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/reusable_lib.yaml",
`name: ReusableLib
on:
workflow_call:
inputs:
from:
type: string
jobs:
lib_job:
runs-on: ubuntu-latest
steps:
- run: echo hello-${{ inputs.from }}
`)
// consumerRepo: private, owned by user4.
consumerAPIRepo := createActionsTestRepo(t, user4Token, "workflow-call-cross-repo", true)
consumerRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: consumerAPIRepo.ID})
runner := newMockRunner()
runner.registerAsRepoRunner(t, consumerRepo.OwnerName, consumerRepo.Name, "mock-cross-runner", []string{"ubuntu-latest"}, false)
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/cross-caller.yaml",
`name: CrossCaller
on: push
jobs:
cross_job:
uses: user2/reusable-lib-private/.gitea/workflows/reusable_lib.yaml@main
with:
from: 'consumer'
`)
// Phase 1: no grant. The cross-repo read check fails, and NO ActionRun row gets persisted.
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumerRepo.ID}))
runner.fetchNoTask(t)
// Phase 2: user2 (libRepo owner) adds user4 (consumer owner) as a Collaborative Owner of libRepo.
addCollabReq := NewRequestWithValues(t, "POST",
fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/add", libRepo.OwnerName, libRepo.Name),
map[string]string{"collaborative_owner": user4.Name})
user2Session.MakeRequest(t, addCollabReq, http.StatusOK)
// Phase 3: trigger the workflow again
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, "marker.txt", "trigger after grant")
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumerRepo.ID})
crossJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "cross_job"})
assert.True(t, crossJob.IsReusableCaller)
assert.True(t, crossJob.IsExpanded)
assert.Equal(t, actions_model.StatusWaiting, crossJob.Status)
libJobTask := runner.fetchTask(t)
_, fetchedLibJob, _ := getTaskAndJobAndRunByTaskID(t, libJobTask.Id)
assert.Equal(t, "lib_job", fetchedLibJob.JobID)
assert.Equal(t, crossJob.ID, fetchedLibJob.ParentJobID)
assert.Equal(t, consumerRepo.ID, fetchedLibJob.RepoID)
payload := getWorkflowCallPayloadFromTask(t, libJobTask)
if assert.Len(t, payload.Inputs, 1) {
assert.Equal(t, "consumer", payload.Inputs["from"])
}
crossJob = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: crossJob.ID})
assert.Equal(t, actions_model.StatusRunning, crossJob.Status)
runner.execTask(t, libJobTask, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
crossJob = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: crossJob.ID})
assert.Equal(t, actions_model.StatusSuccess, crossJob.Status)
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusSuccess, run.Status)
})
t.Run("Public caller denied private target even with collaborative owner", func(t *testing.T) {
// Isolates the run.Repo.IsPrivate gate: a public caller must be denied a private target even with a
// collaborative-owner grant, since allowing it would expose private workflow content in a public run.
// libRepo: private, owned by user2.
libAPIRepo := createActionsTestRepo(t, user2Token, "reusable-lib-public-denied", true)
libRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: libAPIRepo.ID})
createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/reusable_lib.yaml",
`name: ReusableLib
on:
workflow_call:
jobs:
lib_job:
runs-on: ubuntu-latest
steps:
- run: echo hello
`)
// Grant first: user2 adds user4 as a collaborative owner of the private libRepo, so the grant is
// satisfied and the public-caller gate is the only thing that can deny access.
addCollabReq := NewRequestWithValues(t, "POST",
fmt.Sprintf("/%s/%s/settings/actions/general/collaborative_owner/add", libRepo.OwnerName, libRepo.Name),
map[string]string{"collaborative_owner": user4.Name})
user2Session.MakeRequest(t, addCollabReq, http.StatusOK)
// consumerRepo: public, owned by user4.
consumerAPIRepo := createActionsTestRepo(t, user4Token, "workflow-call-public-denied", false)
consumerRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: consumerAPIRepo.ID})
runner := newMockRunner()
runner.registerAsRepoRunner(t, consumerRepo.OwnerName, consumerRepo.Name, "mock-public-denied-runner", []string{"ubuntu-latest"}, false)
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/cross-caller.yaml",
`name: CrossCaller
on: push
jobs:
cross_job:
uses: user2/reusable-lib-public-denied/.gitea/workflows/reusable_lib.yaml@main
`)
// Denied: the cross-repo read check fails for the public caller, so NO ActionRun is persisted and no task is dispatched.
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumerRepo.ID}))
runner.fetchNoTask(t)
})
t.Run("Cross-repo callee with same-repo nested uses", func(t *testing.T) {
// A same-repo `uses: ./...` inside a cross-repo reusable callee must resolve relative to the callee's own repo (matching GitHub's behavior), not the original triggering repo.
// Place a util.yaml with a distinguishable job name in BOTH repos to detect mis-resolution.
libAPIRepo := createActionsTestRepo(t, user2Token, "reusable-lib-nested", false)
libRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: libAPIRepo.ID})
createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/util.yaml",
`name: UtilLib
on:
workflow_call:
jobs:
util_lib_job:
runs-on: ubuntu-latest
steps:
- run: echo from-lib
`)
createRepoWorkflowFile(t, user2, user2Token, libRepo, ".gitea/workflows/lib.yaml",
`name: LibNested
on:
workflow_call:
jobs:
call_util_in_lib:
uses: ./.gitea/workflows/util.yaml
`)
consumerAPIRepo := createActionsTestRepo(t, user4Token, "consumer-nested-uses", false)
consumerRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: consumerAPIRepo.ID})
// A *different* util.yaml in the consumer repo: if `./` mis-resolves we'd see this job's name.
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/util.yaml",
`name: UtilConsumer
on:
workflow_call:
jobs:
util_consumer_job:
runs-on: ubuntu-latest
steps:
- run: echo from-consumer
`)
runner := newMockRunner()
runner.registerAsRepoRunner(t, consumerRepo.OwnerName, consumerRepo.Name, "mock-nested-runner", []string{"ubuntu-latest"}, false)
createRepoWorkflowFile(t, user4, user4Token, consumerRepo, ".gitea/workflows/caller.yaml",
`name: NestedCaller
on: push
jobs:
cross_job:
uses: user2/reusable-lib-nested/.gitea/workflows/lib.yaml@main
`)
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumerRepo.ID})
crossJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "cross_job"})
assert.True(t, crossJob.IsReusableCaller)
assert.True(t, crossJob.IsExpanded)
// cross_job's children come from libRepo/lib.yaml - their source must be libRepo + libRepo's commit.
libHead, err := gitrepo.GetBranchCommitID(t.Context(), libRepo, libRepo.DefaultBranch)
require.NoError(t, err)
callUtilJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "call_util_in_lib", ParentJobID: crossJob.ID})
assert.True(t, callUtilJob.IsReusableCaller)
assert.Equal(t, libRepo.ID, callUtilJob.WorkflowSourceRepoID)
assert.Equal(t, libHead, callUtilJob.WorkflowSourceCommitSHA)
// call_util_in_lib has `uses: ./.gitea/workflows/util.yaml`, so its children should come from libRepo/util.yaml
assert.True(t, callUtilJob.IsExpanded)
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "util_lib_job", ParentJobID: callUtilJob.ID})
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "util_consumer_job"})
})
t.Run("Missing callee file", func(t *testing.T) {
// A caller workflow references a callee path that does not exist in the repo.
apiRepo := createActionsTestRepo(t, user2Token, "caller-missing-callee", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
`name: Caller
on: push
jobs:
plain_job:
runs-on: ubuntu-latest
steps:
- run: echo 'job'
call_missing:
uses: ./.gitea/workflows/does-not-exist.yml
`)
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
})
t.Run("Nested caller with missing callee fails instead of blocking", func(t *testing.T) {
// When the expansion hits a terminal error (e.g. missing callee), the emitter must fail the caller and let the run finish as failed, not retry the expansion forever.
apiRepo := createActionsTestRepo(t, user2Token, "nested-caller-missing-callee", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
runner := newMockRunner()
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
`name: Caller
on: push
jobs:
plain_job:
runs-on: ubuntu-latest
steps:
- run: echo 'job'
bad_caller:
needs: plain_job
uses: ./.gitea/workflows/does-not-exist.yml
`)
// plain_job runs first; bad_caller is Blocked on needs and is NOT expanded at creation.
plainTask := runner.fetchTask(t)
_, plainJob, run := getTaskAndJobAndRunByTaskID(t, plainTask.Id)
assert.Equal(t, "plain_job", plainJob.JobID)
badCallerPre := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "bad_caller"})
assert.Equal(t, actions_model.StatusBlocked, badCallerPre.Status)
assert.False(t, badCallerPre.IsExpanded)
runner.execTask(t, plainTask, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
// The emitter now tries to expand bad_caller, hits the missing callee, and fails the caller.
badCaller := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: badCallerPre.ID})
assert.Equal(t, actions_model.StatusFailure, badCaller.Status)
// No children were inserted (the terminal error precedes the child inserts).
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{ParentJobID: badCallerPre.ID}))
finalRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusFailure, finalRun.Status)
runner.fetchNoTask(t) // no task scheduled for the failed caller; the run is not stuck
})
t.Run("Fork PR with secrets: inherit does not leak base repo secrets", func(t *testing.T) {
// user2 owns the base repo, configures a secret, and registers a reusable workflow that declares a required secret.
// The caller workflow uses `secrets: inherit`.
apiBaseRepo := createActionsTestRepo(t, user2Token, "fork-pr-inherit-test", false)
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID})
// Real secret that must never reach a fork PR task.
req := NewRequestWithJSON(t, "PUT",
fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/leaked_secret", baseRepo.OwnerName, baseRepo.Name),
api.CreateOrUpdateSecretOption{Data: "MUST-NOT-LEAK"}).AddTokenAuth(user2Token)
MakeRequest(t, req, http.StatusCreated)
runner := newMockRunner()
runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-fork-runner", []string{"ubuntu-latest"}, false)
createRepoWorkflowFile(t, user2, user2Token, baseRepo, ".gitea/workflows/reusable.yaml",
`name: Reusable
on:
workflow_call:
secrets:
leaked_secret:
jobs:
callee:
runs-on: ubuntu-latest
steps:
- run: echo
`)
createRepoWorkflowFile(t, user2, user2Token, baseRepo, ".gitea/workflows/caller.yaml",
`name: Caller
on: pull_request
jobs:
call_reusable:
uses: ./.gitea/workflows/reusable.yaml
secrets: inherit
`)
// user4 forks
req = NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name),
&api.CreateForkOption{Name: new("fork-pr-inherit-test-fork")}).AddTokenAuth(user4Token)
resp := MakeRequest(t, req, http.StatusAccepted)
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
// user4 pushes a change on the fork and opens a PR to base
doAPICreateFile(user4APICtx, "user4-fix.txt", &api.CreateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: "user4/branch",
Message: "create user4-fix.txt",
Author: api.Identity{Name: user4.Name, Email: user4.Email},
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("fix")),
})(t)
doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":user4/branch")(t)
// Approve the fork PR run.
forkRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, TriggerUserID: user4.ID})
assert.True(t, forkRun.IsForkPullRequest)
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/approve", baseRepo.OwnerName, baseRepo.Name, forkRun.ID))
user2Session.MakeRequest(t, req, http.StatusOK)
task := runner.fetchTask(t)
_, taskJob, taskRun := getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, "callee", taskJob.JobID)
assert.Equal(t, forkRun.ID, taskRun.ID)
// Only the auto-issued tokens should be present. The user-defined `leaked_secret` must not appear.
assert.Contains(t, task.Secrets, "GITEA_TOKEN")
assert.Contains(t, task.Secrets, "GITHUB_TOKEN")
assert.NotContains(t, task.Secrets, "leaked_secret")
for name, value := range task.Secrets {
assert.NotEqual(t, "MUST-NOT-LEAK", value, "secret %q leaked the base repo's secret value into a fork PR task", name)
}
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
})
t.Run("pull_request_target resolves a local reusable workflow at the base commit", func(t *testing.T) {
apiBaseRepo := createActionsTestRepo(t, user2Token, "prt-reusable-test", false)
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID})
runner := newMockRunner()
runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-prt-runner", []string{"ubuntu-latest"}, false)
reusablePath := ".gitea/workflows/reusable.yaml"
// A pull_request_target run's workflow should always come from the base branch.
createRepoWorkflowFile(t, user2, user2Token, baseRepo, reusablePath, `name: Reusable
on:
workflow_call:
jobs:
trusted:
runs-on: ubuntu-latest
steps:
- run: echo trusted
`)
baseFile := createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, ".gitea/workflows/prt.yaml",
getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, "create prt.yaml", `name: PRT
on: pull_request_target
jobs:
call_reusable:
uses: ./.gitea/workflows/reusable.yaml
secrets: inherit
`))
baseSHA := baseFile.Commit.SHA
// user4 forks
req := NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/forks", baseRepo.OwnerName, baseRepo.Name),
&api.CreateForkOption{Name: new("prt-reusable-test-fork")}).AddTokenAuth(user4Token)
resp := MakeRequest(t, req, http.StatusAccepted)
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
// user4 rewrites the reusable workflow the base branch calls into, and opens a PR
req = NewRequest(t, "GET",
fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", forkRepo.OwnerName, forkRepo.Name, reusablePath)).AddTokenAuth(user4Token)
resp = MakeRequest(t, req, http.StatusOK)
forkReusable := DecodeJSON(t, resp, &api.ContentsResponse{})
req = NewRequestWithJSON(t, "PUT",
fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", forkRepo.OwnerName, forkRepo.Name, reusablePath), &api.UpdateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: "fork-branch",
Message: "rewrite the reusable workflow",
Author: api.Identity{Name: user4.Name, Email: user4.Email},
Committer: api.Identity{Name: user4.Name, Email: user4.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
SHA: forkReusable.SHA,
ContentBase64: base64.StdEncoding.EncodeToString([]byte(`name: Reusable
on:
workflow_call:
jobs:
from-fork:
runs-on: ubuntu-latest
steps:
- run: echo from-fork
`)),
}).AddTokenAuth(user4Token)
resp = MakeRequest(t, req, http.StatusOK)
forkHeadSHA := DecodeJSON(t, resp, &api.FileResponse{}).Commit.SHA
require.NotEqual(t, baseSHA, forkHeadSHA)
doAPICreatePullRequest(user4APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, user4.Name+":fork-branch")(t)
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
prtRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID})
assert.Equal(t, actions_module.GithubEventPullRequestTarget, prtRun.TriggerEvent)
assert.True(t, prtRun.IsForkPullRequest)
assert.False(t, prtRun.NeedApproval)
// The run still points at the PR head, but its workflow source must be the base commit.
assert.Equal(t, forkHeadSHA, prtRun.CommitSHA)
assert.Equal(t, baseSHA, prtRun.WorkflowCommitSHA)
caller := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: prtRun.ID, JobID: "call_reusable"})
assert.Equal(t, baseSHA, caller.WorkflowSourceCommitSHA)
assert.NotContains(t, string(caller.ReusableWorkflowContent), "from-fork")
// The caller has no needs, so it is expanded inline at insert time: the child comes from the base branch.
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: prtRun.ID, JobID: "from-fork"})
child := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: prtRun.ID, JobID: "trusted"})
assert.Equal(t, caller.ID, child.ParentJobID)
assert.Equal(t, baseSHA, child.WorkflowSourceCommitSHA)
task := runner.fetchTask(t)
_, taskJob, _ := getTaskAndJobAndRunByTaskID(t, task.Id)
require.Equal(t, "trusted", taskJob.JobID)
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
})
t.Run("Caller alternates expanding across attempts", func(t *testing.T) {
apiRepo := createActionsTestRepo(t, user2Token, "caller-walkback-test", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
runner := newMockRunner()
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-walkback-runner", []string{"ubuntu-latest"}, false)
// Scenario:
// attempt 1: gate succeeds -> caller expands -> inner runs (records inner.AttemptJobID = N)
// attempt 2: rerun gate, mock Failure -> caller is Skipped without expanding (no children inserted)
// attempt 3: rerun gate, mock Success -> caller expands again -> inner.AttemptJobID must equal N
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/lib.yaml",
`name: Lib
on:
workflow_call:
jobs:
inner:
runs-on: ubuntu-latest
steps:
- run: echo inner
`)
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/main.yaml",
`name: Main
on:
push:
paths:
- '.gitea/workflows/main.yaml'
jobs:
gate:
runs-on: ubuntu-latest
steps:
- run: echo gate
caller:
needs: [gate]
uses: ./.gitea/workflows/lib.yaml
`)
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID})
runID := run.ID
latestAttempt := func() *actions_model.ActionRunAttempt {
r := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
return unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: r.LatestAttemptID})
}
jobInLatest := func(jobID string) *actions_model.ActionRunJob {
a := latestAttempt()
return unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: a.ID, JobID: jobID})
}
// attempt 1: gate Success -> caller expands -> inner runs
gate1Task := runner.fetchTask(t)
_, gate1, _ := getTaskAndJobAndRunByTaskID(t, gate1Task.Id)
assert.Equal(t, "gate", gate1.JobID)
runner.execTask(t, gate1Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
inner1Task := runner.fetchTask(t)
_, inner1, _ := getTaskAndJobAndRunByTaskID(t, inner1Task.Id)
assert.Equal(t, "inner", inner1.JobID)
innerAttemptJobID := inner1.AttemptJobID
callerAttempt1 := jobInLatest("caller")
assert.True(t, callerAttempt1.IsExpanded)
assert.Equal(t, callerAttempt1.ID, inner1.ParentJobID)
runner.execTask(t, inner1Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess, run.Status)
// attempt 2: rerun gate, mock Failure -> caller stays unexpanded (Skipped)
gateLatest := jobInLatest("gate")
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, runID, gateLatest.ID))
user2Session.MakeRequest(t, req, http.StatusOK)
gate2Task := runner.fetchTask(t)
_, gate2, _ := getTaskAndJobAndRunByTaskID(t, gate2Task.Id)
assert.Equal(t, "gate", gate2.JobID)
runner.execTask(t, gate2Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_FAILURE})
runner.fetchNoTask(t) // no inner because caller did not expand
attempt2 := latestAttempt()
assert.Equal(t, actions_model.StatusFailure, attempt2.Status)
callerAttempt2 := jobInLatest("caller")
assert.Equal(t, actions_model.StatusSkipped, callerAttempt2.Status)
assert.False(t, callerAttempt2.IsExpanded)
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRunJob{RunID: runID, RunAttemptID: attempt2.ID, JobID: "inner"}))
// attempt 3: rerun gate, mock Success -> caller expands and inner reuses attempt 1's AttemptJobID
gateLatest = jobInLatest("gate")
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", repo.OwnerName, repo.Name, runID, gateLatest.ID))
user2Session.MakeRequest(t, req, http.StatusOK)
gate3Task := runner.fetchTask(t)
runner.execTask(t, gate3Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
inner3Task := runner.fetchTask(t)
_, inner3, _ := getTaskAndJobAndRunByTaskID(t, inner3Task.Id)
assert.Equal(t, "inner", inner3.JobID)
assert.Equal(t, innerAttemptJobID, inner3.AttemptJobID)
runner.execTask(t, inner3Task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess, run.Status)
})
t.Run("No-needs caller if evaluated inline: false skips, true expands", func(t *testing.T) {
// A no-needs reusable-workflow caller is processed inline during InsertRun, where its own
// `if:` is now evaluated before expansion:
// - a false `if:` skips the caller without inserting any children, and the skip is
// propagated to a dependent job (via the post-commit emitter kick);
// - a true `if:` still expands the caller into its child jobs.
apiRepo := createActionsTestRepo(t, user2Token, "caller-inline-if-test", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/lib.yaml",
`name: Lib
on:
workflow_call:
jobs:
inner:
runs-on: ubuntu-latest
steps:
- run: echo inner
`)
createRepoWorkflowFile(t, user2, user2Token, repo, ".gitea/workflows/caller.yaml",
`name: Caller
on:
push:
paths:
- '.gitea/workflows/caller.yaml'
jobs:
will_skip:
if: ${{ false }}
uses: ./.gitea/workflows/lib.yaml
will_run:
if: ${{ true }}
uses: ./.gitea/workflows/lib.yaml
after_skip:
needs: [will_skip]
runs-on: ubuntu-latest
steps:
- run: echo after_skip
`)
// drain the emitter queue so the skip has propagated to the dependent job
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID})
runID := run.ID
// will_skip: a caller with a false `if:` is skipped inline and never expands (no children inserted).
willSkip := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "will_skip"})
assert.True(t, willSkip.IsReusableCaller)
assert.False(t, willSkip.IsExpanded)
assert.Equal(t, actions_model.StatusSkipped, willSkip.Status)
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: runID, ParentJobID: willSkip.ID})
// will_run: a caller with a true `if:` still expands into its child job.
willRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "will_run"})
assert.True(t, willRun.IsReusableCaller)
assert.True(t, willRun.IsExpanded)
innerChild := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "inner"})
assert.Equal(t, willRun.ID, innerChild.ParentJobID)
// after_skip: a dependent of the skipped caller resolves to Skipped instead of staying Blocked.
afterSkip := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "after_skip"})
assert.Equal(t, actions_model.StatusSkipped, afterSkip.Status)
})
})
}
// token must belong to u (the commit identity) and have write access to repo. Reuse the caller's
// existing token rather than logging in per call, which would re-run bcrypt password verification each time.
func createRepoWorkflowFile(t *testing.T, u *user_model.User, token string, repo *repo_model.Repository, treePath, content string) {
opts := getWorkflowCreateFileOptions(u, repo.DefaultBranch, "create "+treePath, content)
createWorkflowFile(t, token, repo.OwnerName, repo.Name, treePath, opts)
}
func getWorkflowCallPayloadFromTask(t *testing.T, runnerTask *runnerv1.Task) *api.WorkflowCallPayload {
eventJSON, err := runnerTask.GetContext().Fields["event"].GetStructValue().MarshalJSON()
assert.NoError(t, err)
var payload api.WorkflowCallPayload
assert.NoError(t, json.Unmarshal(eventJSON, &payload))
return &payload
}
@@ -9,15 +9,15 @@ import (
"net/url"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
actions_web "code.gitea.io/gitea/routers/web/repo/actions"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
actions_web "gitea.dev/routers/web/repo/actions"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -63,19 +63,51 @@ jobs:
task2 := runner2.fetchTask(t)
_, job2, run2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), repo1.ID, run1.ID, job1.RunAttemptID, job1.ID, 0, "text/markdown", []byte("### Hello summary\n\nFrom first step.\n")))
require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), repo1.ID, run1.ID, job1.RunAttemptID, job1.ID, 1, "text/markdown", []byte("From second step.\n")))
// A second job's summary in the same run/attempt: the run view must include it,
// but the single-job view must scope it out.
otherJobID := job1.ID + 1
require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), repo1.ID, run1.ID, job1.RunAttemptID, otherJobID, 0, "text/markdown", []byte("### Other job summary\n")))
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, run1.ID))
user2Session.MakeRequest(t, req, http.StatusOK)
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, 999999))
user2Session.MakeRequest(t, req, http.StatusNotFound)
// run1 and job1 belong to repo1, success
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run1.ID, job1.ID))
findSummary := func(viewResp *actions_web.ViewResponse, jobID int64) *actions_web.ViewJobSummary {
for _, s := range viewResp.State.Run.JobSummaries {
if s.JobID == jobID {
return s
}
}
return nil
}
assertJob1Summary := func(t *testing.T, s *actions_web.ViewJobSummary) {
t.Helper()
require.NotNil(t, s)
assert.Contains(t, string(s.SummaryHTML), "Hello summary")
assert.Contains(t, string(s.SummaryHTML), "From second step")
}
// Run view: summaries for every job in the run.
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo1.Name, run1.ID))
resp := user2Session.MakeRequest(t, req, http.StatusOK)
var viewResp actions_web.ViewResponse
DecodeJSON(t, resp, &viewResp)
viewResp := DecodeJSON(t, resp, &actions_web.ViewResponse{})
require.Len(t, viewResp.State.Run.JobSummaries, 2)
assertJob1Summary(t, findSummary(viewResp, job1.ID))
assert.Contains(t, string(findSummary(viewResp, otherJobID).SummaryHTML), "Other job summary")
// Job view: scoped server-side to the requested job, the other job's summary excluded.
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run1.ID, job1.ID))
resp = user2Session.MakeRequest(t, req, http.StatusOK)
viewResp = DecodeJSON(t, resp, &actions_web.ViewResponse{})
assert.Len(t, viewResp.State.Run.Jobs, 1)
assert.Equal(t, job1.ID, viewResp.State.Run.Jobs[0].ID)
require.Len(t, viewResp.State.Run.JobSummaries, 1)
assertJob1Summary(t, findSummary(viewResp, job1.ID))
assert.Nil(t, findSummary(viewResp, otherJobID))
// run2 and job2 do not belong to repo1, failure
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d", user2.Name, repo1.Name, run2.ID, job2.ID))
@@ -212,7 +244,7 @@ func testActionsRouteForLegacyIndexBasedURL(t *testing.T) {
// Best-effort compatibility prefers the run ID when the same number also exists as a legacy run index.
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d", user2.Name, repo.Name, collisionRun.Index))
resp = user2Session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), fmt.Sprintf(`data-run-id="%d"`, normalRun.ID)) // because collisionRun.Index == normalRun.ID
assert.Contains(t, resp.Body.String(), fmt.Sprintf(`data-actions-view-url="/%s/%s/actions/runs/%d"`, user2.Name, repo.Name, normalRun.ID))
// by_index=1 should force the summary page to use the legacy run index interpretation.
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d?by_index=1", user2.Name, repo.Name, collisionRun.Index))
@@ -6,14 +6,16 @@ package integration
import (
"fmt"
"net/http"
"strings"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/tests"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/base"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -163,4 +165,62 @@ func TestActionsRunnerModify(t *testing.T) {
assertSuccess(t, sessionAdmin, adminWebURL, globalRunner.ID)
})
})
t.Run("BulkAction", func(t *testing.T) {
// Previous subtests deleted all runners; create a fresh set scoped to this subtest.
require.NoError(t, actions_model.CreateRunner(ctx, &actions_model.ActionRunner{Name: "bulk-runner-1", TokenHash: "e", UUID: "e"}))
require.NoError(t, actions_model.CreateRunner(ctx, &actions_model.ActionRunner{Name: "bulk-runner-2", TokenHash: "f", UUID: "f"}))
require.NoError(t, actions_model.CreateRunner(ctx, &actions_model.ActionRunner{Name: "bulk-runner-3", TokenHash: "g", UUID: "g"}))
r1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{Name: "bulk-runner-1"})
r2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{Name: "bulk-runner-2"})
r3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{Name: "bulk-runner-3"})
allIDs := []int64{r1.ID, r2.ID, r3.ID}
bulkURL := adminWebURL + "/bulk"
doBulk := func(t *testing.T, sess *TestSession, action string, ids []int64, expectedStatus int) {
req := NewRequestWithValues(t, "POST", bulkURL, map[string]string{
"action": action,
"ids": strings.Join(base.Int64sToStrings(ids), ","),
})
sess.MakeRequest(t, req, expectedStatus)
}
t.Run("NonAdminForbidden", func(t *testing.T) {
doBulk(t, sessionUser2, "disable", allIDs, http.StatusForbidden)
for _, id := range allIDs {
v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
assert.False(t, v.IsDisabled, "runner %d should not have been disabled", id)
}
})
t.Run("InvalidAction", func(t *testing.T) {
doBulk(t, sessionAdmin, "evict", allIDs, http.StatusBadRequest)
})
t.Run("EmptyIDs", func(t *testing.T) {
doBulk(t, sessionAdmin, "delete", nil, http.StatusBadRequest)
for _, id := range allIDs {
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
}
})
t.Run("DisableEnable", func(t *testing.T) {
doBulk(t, sessionAdmin, "disable", allIDs, http.StatusOK)
for _, id := range allIDs {
v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
assert.True(t, v.IsDisabled, "runner %d should be disabled", id)
}
doBulk(t, sessionAdmin, "enable", allIDs, http.StatusOK)
for _, id := range allIDs {
v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
assert.False(t, v.IsDisabled, "runner %d should be enabled", id)
}
})
t.Run("Delete", func(t *testing.T) {
doBulk(t, sessionAdmin, "delete", allIDs, http.StatusOK)
for _, id := range allIDs {
unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: id})
}
})
})
}
@@ -0,0 +1,130 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"testing"
"time"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/modules/timeutil"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestFindRunnersSortByStatus verifies that sorting by status ranks runners by
// their computed status (active, then idle, then offline) instead of interleaving
// idle runners with active ones by raw last_online, and that a disabled runner
// sinks to the bottom of its status group rather than mixing with enabled runners.
//
// It lives in tests/integration rather than a unit test because the status rank is
// a database-evaluated CASE expression; unit tests only run against SQLite, so the
// expression must be exercised against MySQL/PostgreSQL/MSSQL in CI to catch dialect
// differences.
func TestFindRunnersSortByStatus(t *testing.T) {
defer tests.PrepareTestEnv(t)()
ctx := t.Context()
const ownerID = 1001
now := time.Now()
// An idle runner that went online most recently would sort before an active
// runner when ordering by last_online alone; the status rank must override that.
insert := func(name string, lastOnline, lastActive time.Time, disabled bool) {
require.NoError(t, db.Insert(ctx, &actions_model.ActionRunner{
Name: name,
UUID: "STATUS-SORT-" + name,
TokenHash: "status-sort-token-" + name,
OwnerID: ownerID,
LastOnline: timeutil.TimeStamp(lastOnline.Unix()),
LastActive: timeutil.TimeStamp(lastActive.Unix()),
IsDisabled: disabled,
}))
}
// Each disabled runner has the most recent last_online within its status group,
// so it would sort first in that group if the disabled flag were ignored; the
// last_active value keeps it in the intended group (offline<idle<active).
insert("active", now.Add(-8*time.Second), now.Add(-5*time.Second), false)
insert("active-disabled", now, now.Add(-2*time.Second), true)
insert("idle", now.Add(-30*time.Second), now.Add(-20*time.Second), false)
insert("idle-disabled", now, now.Add(-20*time.Second), true)
insert("offline", now.Add(-3*time.Minute), now.Add(-3*time.Minute), false)
insert("offline-disabled", now.Add(-90*time.Second), now.Add(-90*time.Second), true)
names := func(runners []*actions_model.ActionRunner) []string {
out := make([]string, len(runners))
for i, r := range runners {
out[i] = r.Name
}
return out
}
// Active group first, then idle, then offline; within each group enabled before disabled.
runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{OwnerID: ownerID, Sort: "online"})
require.NoError(t, err)
assert.Equal(t, []string{
"active", "active-disabled",
"idle", "idle-disabled",
"offline", "offline-disabled",
}, names(runners))
// The descending status sort reverses the group order but still keeps disabled
// runners at the bottom of their group.
runners, err = db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{OwnerID: ownerID, Sort: "offline"})
require.NoError(t, err)
assert.Equal(t, []string{
"offline", "offline-disabled",
"idle", "idle-disabled",
"active", "active-disabled",
}, names(runners))
}
// TestFindRunnersPaginationNoDuplicates verifies that the unique id tiebreaker in
// FindRunnerOptions.ToOrders keeps pagination deterministic when the primary sort
// key (last_online) is tied for every runner. It is an integration test so the
// ordering is validated against every supported database, not only SQLite.
func TestFindRunnersPaginationNoDuplicates(t *testing.T) {
defer tests.PrepareTestEnv(t)()
ctx := t.Context()
// Create several runners that all share the same last_online value so the
// primary sort key (last_online) is tied for all of them.
const ownerID = 1000
const count = 6
for i := range count {
require.NoError(t, db.Insert(ctx, &actions_model.ActionRunner{
Name: "paginated-runner",
UUID: fmt.Sprintf("PAGINATE-TEST-0000-0000-00000000000%d", i),
TokenHash: fmt.Sprintf("paginate-test-token-hash-%d", i),
OwnerID: ownerID,
RepoID: 0,
LastOnline: 42,
}))
}
// Page through the runners and ensure every id is returned exactly once.
seen := make(map[int64]int)
const pageSize = 2
for page := 1; ; page++ {
runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{
ListOptions: db.ListOptions{Page: page, PageSize: pageSize},
OwnerID: ownerID,
})
require.NoError(t, err)
if len(runners) == 0 {
break
}
for _, r := range runners {
seen[r.ID]++
}
}
assert.Len(t, seen, count, "each runner should be returned exactly once across all pages")
for id, n := range seen {
assert.Equal(t, 1, n, "runner %d appeared on %d pages", id, n)
}
}
@@ -10,13 +10,13 @@ import (
"testing"
"time"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/modules/setting"
pingv1 "gitea.dev/actions-proto-go/ping/v1"
"gitea.dev/actions-proto-go/ping/v1/pingv1connect"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
"gitea.dev/actions-proto-go/runner/v1/runnerv1connect"
auth_model "gitea.dev/models/auth"
"gitea.dev/modules/setting"
pingv1 "code.gitea.io/actions-proto-go/ping/v1"
"code.gitea.io/actions-proto-go/ping/v1/pingv1connect"
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
"code.gitea.io/actions-proto-go/runner/v1/runnerv1connect"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -86,10 +86,9 @@ func (r *mockRunner) registerAsRepoRunner(t *testing.T, ownerName, repoName, run
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req := NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/registration-token", ownerName, repoName)).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var registrationToken struct {
registrationToken := DecodeJSON(t, resp, &struct {
Token string `json:"token"`
}
DecodeJSON(t, resp, &registrationToken)
}{})
r.doRegister(t, runnerName, registrationToken.Token, labels, ephemeral)
}
@@ -9,21 +9,25 @@ import (
"strings"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
unit_model "code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/migration"
api "code.gitea.io/gitea/modules/structs"
mirror_service "code.gitea.io/gitea/services/mirror"
repo_service "code.gitea.io/gitea/services/repository"
files_service "code.gitea.io/gitea/services/repository/files"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
unit_model "gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/migration"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
migrations_service "gitea.dev/services/migrations"
mirror_service "gitea.dev/services/mirror"
repo_service "gitea.dev/services/repository"
files_service "gitea.dev/services/repository/files"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestScheduleUpdate(t *testing.T) {
@@ -144,6 +148,11 @@ jobs:
func testScheduleUpdateMirrorSync(t *testing.T) {
doTestScheduleUpdate(t, func(t *testing.T, u *url.URL, testContext APITestContext, user *user_model.User, repo *repo_model.Repository) (commitID, expectedSpec string) {
// the mirror sync re-validates the remote URL, which rejects the local test server unless local
// networks are allowed; migrations.Init rebuilds the host allow-list from the setting
defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)()
require.NoError(t, migrations_service.Init())
// create mirror repo
opts := migration.MigrateOptions{
RepoName: "actions-schedule-mirror",
@@ -0,0 +1,548 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"strconv"
"testing"
"time"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
repo_model "gitea.dev/models/repo"
unit_model "gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/queue"
api "gitea.dev/modules/structs"
"gitea.dev/services/forms"
repo_service "gitea.dev/services/repository"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const scopedPushWorkflow = `name: Scoped Push
on: push
jobs:
scoped-job:
runs-on: ubuntu-latest
steps:
- run: echo scoped
`
const scopedPRWorkflow = `name: Scoped PR
on: pull_request
jobs:
scoped-pr-job:
runs-on: ubuntu-latest
steps:
- run: echo scoped-pr
`
func TestActionsScopedWorkflows(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user2Session := loginUser(t, user2.Name)
user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
// createTestRepo creates an Actions-enabled repo owned by user2, used as a scoped-workflow source or consumer.
createTestRepo := func(t *testing.T, name string, private bool) *repo_model.Repository {
apiRepo := createActionsTestRepo(t, user2Token, name, private)
return unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
}
// registerUserScopedSource registers `source` as a user-level scoped-workflow source for user2 and marks `required` entry names
registerUserScopedSource := func(t *testing.T, source *repo_model.Repository, required ...string) {
addReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/add",
map[string]string{"repo_name": source.Name})
user2Session.MakeRequest(t, addReq, http.StatusOK)
t.Cleanup(func() {
removeReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/remove",
map[string]string{"repo_id": strconv.FormatInt(source.ID, 10)})
user2Session.MakeRequest(t, removeReq, http.StatusOK)
})
if len(required) > 0 {
vals := url.Values{"repo_id": {strconv.FormatInt(source.ID, 10)}, "workflow_ids": required, "required_workflow_ids": required}
for _, id := range required {
// a pattern that matches the source's scoped check regardless of its `name:` (each test source has one workflow)
vals.Set("required_patterns["+id+"]", source.FullName()+": * / *")
}
reqReq := NewRequestWithURLValues(t, "POST", "/user/settings/actions/scoped-workflows/required", vals)
user2Session.MakeRequest(t, reqReq, http.StatusOK)
}
}
t.Run("Trigger and run creation", func(t *testing.T) {
// Registered at INSTANCE level via the admin route (owner/name resolution + OwnerID=0 storage);
// the trigger->execute->rerun below proves an instance-level source drives a consumer run end-to-end and that a rerun stays scoped.
adminSession := loginUser(t, "user1")
source := createTestRepo(t, "sw-trigger-source", false)
// commit the scoped workflow BEFORE registering so the source's own push does not self-trigger.
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
adminAdd := NewRequestWithValues(t, "POST", "/-/admin/actions/scoped-workflows/add", map[string]string{"repo_name": source.FullName()})
adminSession.MakeRequest(t, adminAdd, http.StatusOK)
t.Cleanup(func() {
rm := NewRequestWithValues(t, "POST", "/-/admin/actions/scoped-workflows/remove", map[string]string{"repo_id": strconv.FormatInt(source.ID, 10)})
adminSession.MakeRequest(t, rm, http.StatusOK)
})
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: 0, SourceRepoID: source.ID})
consumer := createTestRepo(t, "sw-trigger-consumer", false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-trigger-runner", []string{"ubuntu-latest"}, false)
createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true})
assert.Equal(t, source.ID, run.WorkflowRepoID, "content source is the source repo")
assert.Equal(t, "push.yaml", run.WorkflowID)
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID}), "only the scoped run, no repo-level run")
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID})
// runs in the CONSUMER's context and reaches a terminal state
task := runner.fetchTask(t)
_, taskJob, taskRun := getTaskAndJobAndRunByTaskID(t, task.Id)
assert.Equal(t, consumer.ID, taskJob.RepoID)
assert.Equal(t, run.ID, taskRun.ID)
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusSuccess, run.Status)
// rerun: the rerun is still a scoped run and again executes in the consumer's context
rerunReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/jobs/%d/rerun", consumer.OwnerName, consumer.Name, run.ID, job.ID))
user2Session.MakeRequest(t, rerunReq, http.StatusOK)
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{RunID: run.ID, Attempt: 2})
task2 := runner.fetchTask(t)
_, taskJob2, taskRun2 := getTaskAndJobAndRunByTaskID(t, task2.Id)
assert.Equal(t, consumer.ID, taskJob2.RepoID)
assert.True(t, taskRun2.IsScopedRun, "the rerun is still a scoped run")
runner.execTask(t, task2, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
assert.Equal(t, actions_model.StatusSuccess, run.Status)
})
t.Run("Opt-out", func(t *testing.T) {
// opt-out: a consumer can disable a non-required scoped workflow, but a required one cannot be disabled.
source := createTestRepo(t, "sw-optout-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
registerUserScopedSource(t, source) // non-required
// non-required: the kebab "Disable Workflow" item is an active link; disabling then makes a push produce no scoped run.
consumer := createTestRepo(t, "sw-optout-consumer", false)
optBody := user2Session.MakeRequest(t, NewRequest(t, "GET",
fmt.Sprintf("/%s/%s/actions?workflow=push.yaml&scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID)),
http.StatusOK).Body.String()
assert.Contains(t, optBody, "Disable Workflow")
assert.Contains(t, optBody, "disable?workflow=push.yaml", "non-required scoped workflow: Disable Workflow is a clickable link")
disableReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/disable?workflow=push.yaml&scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID))
user2Session.MakeRequest(t, disableReq, http.StatusOK)
createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "opted-out scoped workflow must not run")
// required: the kebab "Disable Workflow" item is rendered disabled (no link), and the disable endpoint rejects it.
reqSource := createTestRepo(t, "sw-optout-req-source", false)
createRepoWorkflowFile(t, user2, user2Token, reqSource, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
registerUserScopedSource(t, reqSource, "push.yaml") // required
reqConsumer := createTestRepo(t, "sw-optout-req-consumer", false)
requiredBody := user2Session.MakeRequest(t, NewRequest(t, "GET",
fmt.Sprintf("/%s/%s/actions?workflow=push.yaml&scoped_workflow_source_repo_id=%d", reqConsumer.OwnerName, reqConsumer.Name, reqSource.ID)),
http.StatusOK).Body.String()
assert.Contains(t, requiredBody, "Disable Workflow")
assert.Contains(t, requiredBody, `class="item disabled"`, "required scoped workflow: Disable Workflow is rendered disabled")
assert.NotContains(t, requiredBody, "disable?workflow=push.yaml", "required scoped workflow: Disable Workflow has no clickable link")
rejectReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/disable?workflow=push.yaml&scoped_workflow_source_repo_id=%d", reqConsumer.OwnerName, reqConsumer.Name, reqSource.ID))
user2Session.MakeRequest(t, rejectReq, http.StatusBadRequest) // scoped_required_cannot_disable
})
t.Run("Local uses resolves to source", func(t *testing.T) {
// uses: ./ in a scoped workflow resolves against the SOURCE repo, not the consumer.
// Here the reusable lib lives in the SCOPED workflow dir (allowed by ResolveUses), exercising that path end-to-end.
source := createTestRepo(t, "sw-uses-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/lib.yaml", `name: Lib
on:
workflow_call:
jobs:
lib_job_source:
runs-on: ubuntu-latest
steps:
- run: echo from-source
`)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/caller.yaml", `name: Caller
on: push
jobs:
caller_job:
uses: ./.gitea/scoped_workflows/lib.yaml
`)
consumer := createTestRepo(t, "sw-uses-consumer", false)
// a DIFFERENT lib at the same path in the consumer; if uses:./ mis-resolved we would see this job
createRepoWorkflowFile(t, user2, user2Token, consumer, ".gitea/scoped_workflows/lib.yaml", `name: Lib
on:
workflow_call:
jobs:
lib_job_consumer:
runs-on: ubuntu-latest
steps:
- run: echo from-consumer
`)
// register only AFTER both repos' scoped files exist, so the setup pushes do not trigger
registerUserScopedSource(t, source)
createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "caller.yaml"})
callerJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "caller_job"})
assert.True(t, callerJob.IsReusableCaller)
assert.True(t, callerJob.IsExpanded)
assert.Equal(t, source.ID, callerJob.WorkflowSourceRepoID, "top-level caller's content source is the source repo")
// the expanded child comes from the SOURCE's lib.yaml, not the consumer's same-path file
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "lib_job_source", ParentJobID: callerJob.ID})
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: run.ID, JobID: "lib_job_consumer"})
})
t.Run("Workflow dispatch", func(t *testing.T) {
// a scoped on:workflow_dispatch workflow can be triggered manually from the consumer, via both the web form and the API
source := createTestRepo(t, "sw-dispatch-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/dispatch.yaml", `name: Scoped Dispatch
on: workflow_dispatch
jobs:
dispatch-job:
runs-on: ubuntu-latest
steps:
- run: echo dispatch
`)
registerUserScopedSource(t, source)
consumer := createTestRepo(t, "sw-dispatch-consumer", false)
// web form: /run?...&scoped_workflow_source_repo_id=
webReq := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/run?workflow=dispatch.yaml&scoped_workflow_source_repo_id=%d&ref=refs/heads/%s",
consumer.OwnerName, consumer.Name, source.ID, consumer.DefaultBranch))
user2Session.MakeRequest(t, webReq, http.StatusSeeOther)
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "dispatch.yaml"})
assert.Equal(t, source.ID, run.WorkflowRepoID, "content source is the source repo")
assert.NotEmpty(t, run.WorkflowCommitSHA, "scoped dispatch records the source default-branch commit")
assert.Contains(t, run.Ref, consumer.DefaultBranch, "dispatch runs on the chosen consumer ref")
// API: /actions/workflows/dispatch.yaml/dispatches?scoped_workflow_source_repo_id=
apiReq := NewRequestWithURLValues(t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/dispatch.yaml/dispatches?scoped_workflow_source_repo_id=%d", consumer.OwnerName, consumer.Name, source.ID),
url.Values{"ref": {consumer.DefaultBranch}}).AddTokenAuth(user2Token)
MakeRequest(t, apiReq, http.StatusNoContent)
assert.Equal(t, 2, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowID: "dispatch.yaml", Event: "workflow_dispatch"}),
"both the web form and the API created a scoped dispatch run")
})
t.Run("Required scoped check gates the PR merge", func(t *testing.T) {
// A required scoped workflow's check gates PR merges on a protected branch and cannot be bypassed,
// whether or not the branch enables its own status check. The scoped check is added to the required set dynamically.
source := createTestRepo(t, "sw-gate-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/pr.yaml", scopedPRWorkflow)
registerUserScopedSource(t, source, "pr.yaml") // required
// protectAndOpenPR protects consumer's default branch and opens a PR on `branch`, returning a merge-request builder.
// When statusCheckEnabled it also configures "ci/manual" as the only CONFIGURED required context and satisfies it,
// so the scoped check is the only thing that can gate the merge; otherwise the rule's own status check stays off.
protectAndOpenPR := func(t *testing.T, consumer *repo_model.Repository, branch string, statusCheckEnabled bool) func() *RequestWrapper {
pbValues := map[string]string{
"rule_name": consumer.DefaultBranch,
"enable_push": "true",
"block_admin_merge_override": "true", // otherwise the repo owner bypasses the status check
}
if statusCheckEnabled {
pbValues["enable_status_check"] = "true"
pbValues["status_check_contexts"] = "ci/manual"
}
user2Session.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", consumer.OwnerName, consumer.Name), pbValues), http.StatusSeeOther)
prFile := &api.CreateFileOptions{
FileOptions: api.FileOptions{
BranchName: consumer.DefaultBranch, NewBranchName: branch, Message: "pr change",
Author: api.Identity{Name: user2.Name, Email: user2.Email},
Committer: api.Identity{Name: user2.Name, Email: user2.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("pr change")),
}
createWorkflowFile(t, user2Token, consumer.OwnerName, consumer.Name, "pr-change.txt", prFile)
apiCtx := NewAPITestContext(t, user2.Name, consumer.Name, auth_model.AccessTokenScopeWriteRepository)
pr, err := doAPICreatePullRequest(apiCtx, consumer.OwnerName, consumer.Name, consumer.DefaultBranch, branch)(t)
require.NoError(t, err)
if statusCheckEnabled {
// satisfy the configured "ci/manual" check so only the scoped check can gate the merge
manualStatus := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", consumer.OwnerName, consumer.Name, pr.Head.Sha),
api.CreateStatusOption{State: commitstatus.CommitStatusSuccess, Context: "ci/manual", TargetURL: "http://test.ci/"}).AddTokenAuth(user2Token)
user2Session.MakeRequest(t, manualStatus, http.StatusCreated)
}
return func() *RequestWrapper {
return NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", consumer.OwnerName, consumer.Name, pr.Index),
&forms.MergePullRequestForm{Do: string(repo_model.MergeStyleMerge), MergeMessageField: "merge"}).AddTokenAuth(user2Token)
}
}
t.Run("pending blocks, success allows", func(t *testing.T) {
consumer := createTestRepo(t, "sw-gate-consumer", false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-gate-runner", []string{"ubuntu-latest"}, false)
mergeReq := protectAndOpenPR(t, consumer, "gate-pr", true)
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true})
assert.Equal(t, source.ID, run.WorkflowRepoID)
// the pending required scoped check blocks the merge
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed)
// the required scoped run succeeds -> merge allowed
task := runner.fetchTask(t)
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
user2Session.MakeRequest(t, mergeReq(), http.StatusOK)
})
t.Run("Actions disabled blocks merge (no bypass)", func(t *testing.T) {
// must-present: disabling the consumer's Actions unit so the required scoped workflow cannot run.
// Must BLOCK the merge (the required check is absent), not bypass it.
consumer := createTestRepo(t, "sw-noact-consumer", false)
require.NoError(t, repo_service.UpdateRepositoryUnits(t.Context(), consumer, nil, []unit_model.Type{unit_model.TypeActions}))
mergeReq := protectAndOpenPR(t, consumer, "noact-pr", true)
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}),
"Actions disabled, so no scoped run is created")
// the required scoped check never posted a status -> must-present blocks the merge (no bypass)
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed)
})
t.Run("status check disabled: the scoped check still gates", func(t *testing.T) {
// the scoped check gates the merge even when the branch's OWN status check is off
consumer := createTestRepo(t, "sw-nocheck-consumer", false)
runner := newMockRunner()
runner.registerAsRepoRunner(t, consumer.OwnerName, consumer.Name, "sw-nocheck-runner", []string{"ubuntu-latest"}, false)
mergeReq := protectAndOpenPR(t, consumer, "nocheck-pr", false)
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true})
// pending scoped check blocks the merge despite the branch's own status check being off
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
user2Session.MakeRequest(t, mergeReq(), http.StatusMethodNotAllowed)
// the required scoped run succeeds -> merge allowed
task := runner.fetchTask(t)
runner.execTask(t, task, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
user2Session.MakeRequest(t, mergeReq(), http.StatusOK)
})
})
t.Run("Filtered required scoped check passes as skipped and allows merge", func(t *testing.T) {
// A required scoped workflow excluded by a paths filter posts a skipped (success) commit status,
// so the required check is satisfied and the PR can merge.
const scopedFilteredPRWorkflow = `name: Scoped Filtered PR
on:
pull_request:
paths:
- src/**
jobs:
scoped-filtered-job:
runs-on: ubuntu-latest
steps:
- run: echo scoped-filtered
`
source := createTestRepo(t, "sw-filtered-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/pr.yaml", scopedFilteredPRWorkflow)
registerUserScopedSource(t, source, "pr.yaml") // required
consumer := createTestRepo(t, "sw-filtered-consumer", false)
// Protect the default branch (its own status check stays off, so only the required scoped check gates the merge).
user2Session.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", consumer.OwnerName, consumer.Name), map[string]string{
"rule_name": consumer.DefaultBranch,
"enable_push": "true",
"block_admin_merge_override": "true", // otherwise the repo owner bypasses the status check
}), http.StatusSeeOther)
// Open a PR that changes a file NOT matching the workflow's `paths: [src/**]`, so it is filtered out.
prFile := &api.CreateFileOptions{
FileOptions: api.FileOptions{
BranchName: consumer.DefaultBranch, NewBranchName: "filtered-pr", Message: "pr change",
Author: api.Identity{Name: user2.Name, Email: user2.Email},
Committer: api.Identity{Name: user2.Name, Email: user2.Email},
Dates: api.CommitDateOptions{Author: time.Now(), Committer: time.Now()},
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("pr change")),
}
createWorkflowFile(t, user2Token, consumer.OwnerName, consumer.Name, "docs.txt", prFile)
apiCtx := NewAPITestContext(t, user2.Name, consumer.Name, auth_model.AccessTokenScopeWriteRepository)
pr, err := doAPICreatePullRequest(apiCtx, consumer.OwnerName, consumer.Name, consumer.DefaultBranch, "filtered-pr")(t)
require.NoError(t, err)
// Filtered: no scoped run is created, but a skipped commit status is posted on the PR head.
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "filtered scoped workflow creates no run")
assertSkippedCommitStatusExists(t, consumer.ID, pr.Head.Sha, "pull_request")
// The skipped (success) status satisfies the required scoped check (prefixed with the source repo), so the merge is allowed.
assert.NoError(t, queue.GetManager().FlushAll(t.Context(), 5*time.Second))
mergeReq := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", consumer.OwnerName, consumer.Name, pr.Index),
&forms.MergePullRequestForm{Do: string(repo_model.MergeStyleMerge), MergeMessageField: "merge"}).AddTokenAuth(user2Token)
user2Session.MakeRequest(t, mergeReq, http.StatusOK)
})
t.Run("Settings page required patterns", func(t *testing.T) {
source := createTestRepo(t, "sw-settings-source", false)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/push.yaml", scopedPushWorkflow)
createRepoWorkflowFile(t, user2, user2Token, source, ".gitea/scoped_workflows/manual.yaml", `name: Manual
on: workflow_dispatch
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo
`) // workflow_dispatch posts no status -> the settings page must warn instead of listing contexts
registerUserScopedSource(t, source) // registered; each phase configures it via the /required endpoint
pattern := source.FullName() + ": * / *"
setConfigs := func(t *testing.T, vals url.Values) {
vals.Set("repo_id", strconv.FormatInt(source.ID, 10))
user2Session.MakeRequest(t, NewRequestWithURLValues(t, "POST", "/user/settings/actions/scoped-workflows/required", vals), http.StatusOK)
}
loadSource := func(t *testing.T) *actions_model.ActionScopedWorkflowSource {
return unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: user2.ID, SourceRepoID: source.ID})
}
settingsBody := func(t *testing.T) string {
return user2Session.MakeRequest(t, NewRequest(t, "GET", "/user/settings/actions/scoped-workflows"), http.StatusOK).Body.String()
}
t.Run("renders the saved pattern and display-name default", func(t *testing.T) {
setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}})
body := settingsBody(t)
assert.Contains(t, body, `name="required_patterns[push.yaml]"`, "patterns textarea uses the field name the parser expects")
assert.Contains(t, body, pattern, "the saved pattern round-trips into the textarea")
// the default prefill must use the workflow display name so it matches the status context the run posts (name: Scoped Push)
assert.Contains(t, body, `data-default-pattern="`+source.FullName()+`: Scoped Push / *"`)
// the expected-checks preview derives the exact context a run posts (job scoped-job, event push) for live glob matching
assert.Contains(t, body, `data-context="`+source.FullName()+`: Scoped Push / scoped-job (push)"`)
})
t.Run("live pattern kept as history after un-require", func(t *testing.T) {
setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}})
// un-require: the row still submits workflow_ids + its patterns (the hidden textarea), but not required_workflow_ids
setConfigs(t, url.Values{"workflow_ids": {"push.yaml"}, "required_patterns[push.yaml]": {pattern}})
cfg := loadSource(t).WorkflowConfigs["push.yaml"]
require.NotNil(t, cfg)
assert.False(t, cfg.Required, "no longer required")
assert.Equal(t, []string{pattern}, cfg.Patterns, "pattern retained as history")
assert.Contains(t, settingsBody(t), pattern, "history pattern still rendered, so re-requiring restores it")
})
t.Run("orphan config dropped when un-required", func(t *testing.T) {
// An orphan entry (gone.yaml: required for a file that no longer exists in the source) has no history worth keeping:
// un-checking Required must drop it entirely, unlike a live un-required workflow.
setConfigs(t, url.Values{
"workflow_ids": {"push.yaml", "gone.yaml"}, "required_workflow_ids": {"push.yaml", "gone.yaml"},
"required_patterns[push.yaml]": {pattern}, "required_patterns[gone.yaml]": {pattern},
})
require.True(t, loadSource(t).IsWorkflowRequired("gone.yaml"), "orphan kept while still required")
// un-require gone.yaml (its row + patterns are still submitted, as the settings page does); push.yaml stays required
setConfigs(t, url.Values{
"workflow_ids": {"push.yaml", "gone.yaml"}, "required_workflow_ids": {"push.yaml"},
"required_patterns[push.yaml]": {pattern}, "required_patterns[gone.yaml]": {pattern},
})
src := loadSource(t)
assert.Nil(t, src.WorkflowConfigs["gone.yaml"], "orphan dropped after un-require, not kept as history")
assert.True(t, src.IsWorkflowRequired("push.yaml"), "live required workflow kept")
})
t.Run("warns when a workflow posts no status checks", func(t *testing.T) {
// manual.yaml only runs on workflow_dispatch, which posts no commit status: instead of listing expected checks,
// its row shows a warning not to mark it required (must-present would block forever).
body := settingsBody(t)
assert.Contains(t, body, "posts no status checks", "the no-status-check warning is shown")
assert.NotContains(t, body, `data-context="`+source.FullName()+`: Manual /`, "a workflow_dispatch-only workflow must list no expected contexts")
})
})
t.Run("Distinct sources same filename", func(t *testing.T) {
// two DIFFERENT source repos with the same filename run independently
s1 := createTestRepo(t, "sw-multi-s1", false)
createRepoWorkflowFile(t, user2, user2Token, s1, ".gitea/scoped_workflows/ci.yaml", scopedPushWorkflow)
s2 := createTestRepo(t, "sw-multi-s2", false)
createRepoWorkflowFile(t, user2, user2Token, s2, ".gitea/scoped_workflows/ci.yaml", scopedPushWorkflow)
registerUserScopedSource(t, s1)
registerUserScopedSource(t, s2)
consumer := createTestRepo(t, "sw-multi-consumer", false)
createRepoWorkflowFile(t, user2, user2Token, consumer, "marker.txt", "trigger")
assert.Equal(t, 2, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}), "same-named ci.yaml from two sources run independently")
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowRepoID: s1.ID})
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, WorkflowRepoID: s2.ID})
})
t.Run("Detection cache invalidates on source push", func(t *testing.T) {
// The detection parse is cached per (source, default-branch SHA).
source := createTestRepo(t, "sw-cache-source", false)
created := createWorkflowFile(t, user2Token, source.OwnerName, source.Name, ".gitea/scoped_workflows/ci.yaml",
getWorkflowCreateFileOptions(user2, source.DefaultBranch, "create ci", `name: CI
on: pull_request
jobs:
j:
runs-on: ubuntu-latest
steps:
- run: echo a
`))
registerUserScopedSource(t, source)
consumer := createTestRepo(t, "sw-cache-consumer", false)
// warm the cache at the source's current SHA: the source triggers on pull_request, so a consumer push is no match
createRepoWorkflowFile(t, user2, user2Token, consumer, "m1.txt", "trigger")
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true}),
"source triggers on pull_request, so a consumer push must not create a scoped run")
// switch the source's trigger to push on its default branch
updateReq := NewRequestWithJSON(t, "PUT",
fmt.Sprintf("/api/v1/repos/%s/%s/contents/.gitea/scoped_workflows/ci.yaml", source.OwnerName, source.Name),
&api.UpdateFileOptions{
SHA: created.Content.SHA,
FileOptions: api.FileOptions{BranchName: source.DefaultBranch, Message: "switch to push"},
ContentBase64: base64.StdEncoding.EncodeToString([]byte(`name: CI
on: push
jobs:
j:
runs-on: ubuntu-latest
steps:
- run: echo a
`)),
}).AddTokenAuth(user2Token)
MakeRequest(t, updateReq, http.StatusOK)
// the next consumer push must re-detect against the new SHA (on: push) and create the scoped run
createRepoWorkflowFile(t, user2, user2Token, consumer, "m2.txt", "trigger")
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: consumer.ID, IsScopedRun: true, Event: "push"}),
"after the source switches to on: push, the next consumer push creates a scoped run")
})
t.Run("Deletion cleans up source registration", func(t *testing.T) {
source := createTestRepo(t, "sw-delete-source", false)
addReq := NewRequestWithValues(t, "POST", "/user/settings/actions/scoped-workflows/add", map[string]string{"repo_name": source.Name})
user2Session.MakeRequest(t, addReq, http.StatusOK)
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionScopedWorkflowSource{OwnerID: user2.ID, SourceRepoID: source.ID})
delReq := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s", source.OwnerName, source.Name)).AddTokenAuth(user2Token)
MakeRequest(t, delReq, http.StatusNoContent)
unittest.AssertNotExistsBean(t, &actions_model.ActionScopedWorkflowSource{SourceRepoID: source.ID})
})
})
}
@@ -9,10 +9,10 @@ import (
"net/url"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
auth_model "gitea.dev/models/auth"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
)
@@ -12,30 +12,30 @@ import (
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
actions_module "code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/commitstatus"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/timeutil"
webhook_module "code.gitea.io/gitea/modules/webhook"
issue_service "code.gitea.io/gitea/services/issue"
pull_service "code.gitea.io/gitea/services/pull"
release_service "code.gitea.io/gitea/services/release"
repo_service "code.gitea.io/gitea/services/repository"
commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus"
files_service "code.gitea.io/gitea/services/repository/files"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/gitrepo"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
webhook_module "gitea.dev/modules/webhook"
issue_service "gitea.dev/services/issue"
pull_service "gitea.dev/services/pull"
release_service "gitea.dev/services/release"
repo_service "gitea.dev/services/repository"
commitstatus_service "gitea.dev/services/repository/commitstatus"
files_service "gitea.dev/services/repository/files"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -166,6 +166,14 @@ jobs:
assert.Equal(t, addFileToForkedResp.Commit.SHA, actionRun.CommitSHA)
assert.Equal(t, actions_module.GithubEventPullRequestTarget, actionRun.TriggerEvent)
// require the workflow's status check on the base branch, so a filtered-out run posts a skipped status to satisfy it
require.NoError(t, git_model.UpdateProtectBranch(t.Context(), baseRepo, &git_model.ProtectedBranch{
RepoID: baseRepo.ID,
RuleName: "main",
EnableStatusCheck: true,
StatusCheckContexts: []string{"*"},
}, git_model.WhitelistOptions{}))
// add another file whose name cannot match the specified path
addFileToForkedResp, err = files_service.ChangeRepoFiles(t.Context(), forkedRepo, user4, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
@@ -215,8 +223,68 @@ jobs:
err = pull_service.NewPullRequest(t.Context(), prOpts)
assert.NoError(t, err)
// the new pull request cannot trigger actions, so there is still only 1 record
// the new pull request is filtered by paths, so no run is created; a skipped commit status is posted instead
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
assertSkippedCommitStatusExists(t, baseRepo.ID, addFileToForkedResp.Commit.SHA, "pull_request_target")
// delete the branch protection rule: with nothing required, a filtered-out run must post no skipped status
pb, err := git_model.GetProtectedBranchRuleByName(t.Context(), baseRepo.ID, "main")
require.NoError(t, err)
require.NotNil(t, pb)
require.NoError(t, git_model.DeleteProtectedBranch(t.Context(), baseRepo, pb.ID))
// add another file whose name cannot match the specified path
addFileToForkedResp, err = files_service.ChangeRepoFiles(t.Context(), forkedRepo, user4, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: "bar.txt",
ContentReader: strings.NewReader("bar"),
},
},
Message: "add bar.txt",
OldBranch: "main",
NewBranch: "fork-branch-3",
Author: &files_service.IdentityOptions{
GitUserName: user4.Name,
GitUserEmail: user4.Email,
},
Committer: &files_service.IdentityOptions{
GitUserName: user4.Name,
GitUserEmail: user4.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
})
assert.NoError(t, err)
assert.NotEmpty(t, addFileToForkedResp)
// create Pull
pullIssue = &issues_model.Issue{
RepoID: baseRepo.ID,
Title: "A mismatched path with no required status check posts no skipped status",
PosterID: user4.ID,
Poster: user4,
IsPull: true,
}
pullRequest = &issues_model.PullRequest{
HeadRepoID: forkedRepo.ID,
BaseRepoID: baseRepo.ID,
HeadBranch: "fork-branch-3",
BaseBranch: "main",
HeadRepo: forkedRepo,
BaseRepo: baseRepo,
Type: issues_model.PullRequestGitea,
}
prOpts = &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest}
err = pull_service.NewPullRequest(t.Context(), prOpts)
assert.NoError(t, err)
// filtered by paths and no required status check remains, so no run and no skipped commit status
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
assertNoSkippedCommitStatusExists(t, baseRepo.ID, addFileToForkedResp.Commit.SHA, "pull_request_target")
})
}
@@ -338,6 +406,10 @@ jobs:
})
assert.NoError(t, err)
assert.NotEmpty(t, addFileToBranchResp)
// the push to test-skip-ci is filtered by branches, so no run is created;
// its context is not a required status check either, so no skipped commit status is posted.
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
assertNoSkippedCommitStatusExists(t, repo.ID, addFileToBranchResp.Commit.SHA, "push")
resp := testPullCreate(t, session, "user2", "skip-ci", true, "master", "test-skip-ci", "[skip ci] test-skip-ci")
@@ -345,7 +417,7 @@ jobs:
url := test.RedirectURL(resp)
assert.Regexp(t, "^/user2/skip-ci/pulls/[0-9]*$", url)
// the pr title contains a configured skip-ci string, so there is still only 1 record
// the pr title contains a configured skip-ci string, so no run and no skipped status are created
assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
})
}
@@ -638,7 +710,7 @@ jobs:
return false
}
if latestCommitStatuses[0].State == commitstatus.CommitStatusPending {
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
return true
}
return false
@@ -680,14 +752,18 @@ func checkCommitStatusAndInsertFakeStatus(t *testing.T, repo *repo_model.Reposit
assert.Len(t, latestCommitStatuses, 1)
assert.Equal(t, commitstatus.CommitStatusPending, latestCommitStatuses[0].State)
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
}
func insertFakeStatus(t *testing.T, repo *repo_model.Repository, sha, targetURL, context string) {
// insertFakeStatus inserts a success status that lands in the same dedupe
// group as `prev` — the actions runner mixes the workflow file path into
// ContextHash, so we must reuse it (rather than recomputing from Context).
func insertFakeStatus(t *testing.T, repo *repo_model.Repository, sha string, prev *git_model.CommitStatus) {
err := commitstatus_service.CreateCommitStatus(t.Context(), repo, user_model.NewActionsUser(), sha, &git_model.CommitStatus{
State: commitstatus.CommitStatusSuccess,
TargetURL: targetURL,
Context: context,
State: commitstatus.CommitStatusSuccess,
TargetURL: prev.TargetURL,
Context: prev.Context,
ContextHash: prev.ContextHash,
})
assert.NoError(t, err)
}
@@ -822,7 +898,7 @@ jobs:
return false
}
if latestCommitStatuses[0].State == commitstatus.CommitStatusPending {
insertFakeStatus(t, repo, sha, latestCommitStatuses[0].TargetURL, latestCommitStatuses[0].Context)
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
return true
}
return false
@@ -931,6 +1007,76 @@ jobs:
})
}
func TestWorkflowDispatchPublicApiRequiresWorkflowDispatchTrigger(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
repo, err := repo_service.CreateRepository(t.Context(), user2, user2, repo_service.CreateRepoOptions{
Name: "workflow-dispatch-requires-trigger",
Description: "test workflow dispatch requires workflow_dispatch",
AutoInit: true,
Gitignores: "Go",
License: "MIT",
Readme: "Default",
DefaultBranch: "main",
IsPrivate: false,
})
require.NoError(t, err)
require.NotNil(t, repo)
addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(t.Context(), repo, user2, &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: ".gitea/workflows/push-only.yml",
ContentReader: strings.NewReader(`
on:
push:
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo helloworld
`),
},
},
Message: "add workflow",
OldBranch: "main",
NewBranch: "main",
Author: &files_service.IdentityOptions{
GitUserName: user2.Name,
GitUserEmail: user2.Email,
},
Committer: &files_service.IdentityOptions{
GitUserName: user2.Name,
GitUserEmail: user2.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
})
require.NoError(t, err)
require.NotNil(t, addWorkflowToBaseResp)
values := url.Values{}
values.Set("ref", "main")
req := NewRequestWithURLValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/workflows/push-only.yml/dispatches", repo.FullName()), values).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusUnprocessableEntity)
apiError := DecodeJSON(t, resp, &api.APIError{})
assert.Contains(t, apiError.Message, "has no workflow_dispatch event trigger")
unittest.AssertNotExistsBean(t, &actions_model.ActionRun{
RepoID: repo.ID,
Event: "workflow_dispatch",
WorkflowID: "push-only.yml",
})
})
}
func TestWorkflowDispatchPublicApiWithInputs(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
@@ -1568,8 +1714,7 @@ jobs:
Name: new("close-pull-request-with-path-fork"),
}).AddTokenAuth(user4Token)
resp := MakeRequest(t, req, http.StatusAccepted)
var apiForkRepo api.Repository
DecodeJSON(t, resp, &apiForkRepo)
apiForkRepo := DecodeJSON(t, resp, &api.Repository{})
forkRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiForkRepo.ID})
user4APICtx := NewAPITestContext(t, user4.Name, forkRepo.Name, auth_model.AccessTokenScopeWriteRepository)
@@ -1802,7 +1947,33 @@ jobs:
testEditFile(t, session, "user2", repoName, repo.DefaultBranch, "dir1/dir1.txt", "11")
// update by rebase
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/pulls/%d/update?style=rebase", "user2", repoName, apiPull.Index))
session.MakeRequest(t, req, http.StatusSeeOther)
session.MakeRequest(t, req, http.StatusOK)
runner.fetchNoTask(t)
})
}
// assertSkippedCommitStatusExists asserts that a filtered-out required workflow posted a skipped commit status on sha
func assertSkippedCommitStatusExists(t *testing.T, repoID int64, sha, eventSuffix string) {
t.Helper()
assert.Truef(t, hasSkippedCommitStatus(t, repoID, sha, eventSuffix), "missing skipped commit status with event %q on %s", eventSuffix, sha)
}
// assertNoSkippedCommitStatusExists asserts that no skipped commit status for the given event was posted on sha,
// e.g. a filtered-out workflow whose context is not a required status check must not leave one behind.
func assertNoSkippedCommitStatusExists(t *testing.T, repoID int64, sha, eventSuffix string) {
t.Helper()
assert.Falsef(t, hasSkippedCommitStatus(t, repoID, sha, eventSuffix), "unexpected skipped commit status with event %q on %s", eventSuffix, sha)
}
// hasSkippedCommitStatus reports whether a skipped commit status for the given event was posted on sha.
func hasSkippedCommitStatus(t *testing.T, repoID int64, sha, eventSuffix string) bool {
t.Helper()
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repoID, sha, db.ListOptionsAll)
require.NoError(t, err)
for _, s := range statuses {
if s.State == commitstatus.CommitStatusSkipped && strings.Contains(s.Context, "("+eventSuffix+")") {
return true
}
}
return false
}
@@ -8,12 +8,12 @@ import (
"net/http"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/tests"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -7,11 +7,11 @@ import (
"net/http"
"testing"
"code.gitea.io/gitea/models/system"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/setting/config"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/tests"
"gitea.dev/models/system"
"gitea.dev/modules/setting"
"gitea.dev/modules/setting/config"
"gitea.dev/modules/test"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -21,29 +21,52 @@ func TestAdminConfig(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user1")
req := NewRequest(t, "GET", "/-/admin/config")
resp := session.MakeRequest(t, req, http.StatusOK)
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
t.Run("ConfigPage", func(t *testing.T) {
req := NewRequest(t, "GET", "/-/admin/config")
resp := session.MakeRequest(t, req, http.StatusOK)
assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
})
t.Run("OpenEditorWithApps", func(t *testing.T) {
cfg := setting.Config().Repository.OpenWithEditorApps
editorApps := cfg.Value(t.Context())
assert.Len(t, editorApps, 3)
assert.False(t, cfg.HasValue(t.Context()))
require.NoError(t, system.SetSettings(t.Context(), map[string]string{cfg.DynKey(): "[]"}))
config.GetDynGetter().InvalidateCache()
t.Run("Default", func(t *testing.T) {
editorApps := cfg.Value(t.Context())
assert.Len(t, editorApps, 3)
assert.False(t, cfg.HasValue(t.Context()))
})
editorApps = cfg.Value(t.Context())
assert.Len(t, editorApps, 3)
assert.False(t, cfg.HasValue(t.Context()))
t.Run("EmptyAsDefault", func(t *testing.T) {
require.NoError(t, system.SetSettings(t.Context(), map[string]string{cfg.DynKey(): "[]"}))
config.GetDynGetter().InvalidateCache()
require.NoError(t, system.SetSettings(t.Context(), map[string]string{cfg.DynKey(): "[{}]"}))
config.GetDynGetter().InvalidateCache()
editorApps := cfg.Value(t.Context())
assert.Len(t, editorApps, 3)
assert.False(t, cfg.HasValue(t.Context()))
})
editorApps = cfg.Value(t.Context())
assert.Len(t, editorApps, 1)
assert.True(t, cfg.HasValue(t.Context()))
t.Run("SingleItem", func(t *testing.T) {
require.NoError(t, system.SetSettings(t.Context(), map[string]string{cfg.DynKey(): "[{}]"}))
config.GetDynGetter().InvalidateCache()
editorApps := cfg.Value(t.Context())
assert.Len(t, editorApps, 1)
assert.True(t, cfg.HasValue(t.Context()))
})
t.Run("ManualSet", func(t *testing.T) {
req := NewRequestWithValues(t, "POST", "/-/admin/config", map[string]string{
"key": "repository.open-with.editor-apps",
"value": `[{"DisplayName":"app-name","OpenURL":"my-app:?u={url}"}]`,
})
session.MakeRequest(t, req, http.StatusOK)
editorApps := cfg.Value(t.Context())
assert.Len(t, editorApps, 1)
assert.Equal(t, "app-name", editorApps[0].DisplayName)
assert.Equal(t, "my-app:?u={url}", editorApps[0].OpenURL)
assert.True(t, cfg.HasValue(t.Context()))
})
})
t.Run("InstanceWebBanner", func(t *testing.T) {
@@ -51,7 +74,7 @@ func TestAdminConfig(t *testing.T) {
assert.False(t, has)
assert.Equal(t, setting.WebBannerType{}, banner)
req = NewRequestWithValues(t, "POST", "/-/admin/config", map[string]string{
req := NewRequestWithValues(t, "POST", "/-/admin/config", map[string]string{
"key": "instance.web_banner",
"value": `{"DisplayEnabled":true,"ContentMessage":"test-msg","StartTimeUnix":123,"EndTimeUnix":456}`,
})
@@ -9,9 +9,9 @@ import (
"strconv"
"testing"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/tests"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -4,13 +4,30 @@
package integration
import (
"archive/zip"
"bytes"
"crypto/md5"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
"code.gitea.io/gitea/tests"
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/util"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type uploadArtifactResponse struct {
@@ -30,6 +47,148 @@ func prepareTestEnvActionsArtifacts(t *testing.T) func() {
return f
}
func getArtifactFixtureTask(t *testing.T) *actions_model.ActionTask {
t.Helper()
task, err := actions_model.GetRunningTaskByToken(t.Context(), "8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
require.NoError(t, err)
require.NoError(t, task.LoadJob(t.Context()))
ensureArtifactFixtureTaskSteps(t, task)
return task
}
func ensureArtifactFixtureTaskSteps(t *testing.T, task *actions_model.ActionTask) {
t.Helper()
steps, err := actions_model.GetTaskStepsByTaskID(t.Context(), task.ID)
require.NoError(t, err)
existingIndexes := make(map[int64]bool, len(steps))
for _, step := range steps {
existingIndexes[step.Index] = true
}
var stepsToInsert []*actions_model.ActionTaskStep
for _, idx := range []int64{0, 1} {
if existingIndexes[idx] {
continue
}
stepsToInsert = append(stepsToInsert, &actions_model.ActionTaskStep{
TaskID: task.ID,
Index: idx,
RepoID: task.RepoID,
Status: actions_model.StatusWaiting,
})
}
if len(stepsToInsert) == 0 {
return
}
_, err = db.GetEngine(t.Context()).Insert(stepsToInsert)
require.NoError(t, err)
}
func TestActionsJobSummaryUpload(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
const runnerToken = "8061e833a55f6fc0157c98b883e91fcfeeb1a71a"
task := getArtifactFixtureTask(t)
summaryURL := func(stepIndex int64) string {
return fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/steps/%d/summary", task.Job.RunID, task.Job.ID, stepIndex)
}
putSummary := func(stepIndex int64, body, contentType string) *RequestWrapper {
return NewRequestWithBody(t, "PUT", summaryURL(stepIndex), strings.NewReader(body)).
AddTokenAuth(runnerToken).
SetHeader("Content-Type", contentType)
}
t.Run("success", func(t *testing.T) {
body := "### Uploaded summary\n\n- line one\n"
MakeRequest(t, putSummary(0, body, "text/markdown; charset=utf-8"), http.StatusOK)
summary, err := actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0)
require.NoError(t, err)
assert.Equal(t, actions_model.JobSummaryContentTypeMarkdown, summary.ContentType)
assert.Equal(t, body, summary.Content)
staleUpdated := summary.Updated - 60
_, err = db.GetEngine(t.Context()).ID(summary.ID).Cols("updated").Update(&actions_model.ActionRunJobSummary{Updated: staleUpdated})
require.NoError(t, err)
updatedBody := "### Updated summary\n\n- refreshed\n"
MakeRequest(t, putSummary(0, updatedBody, actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)
summary, err = actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0)
require.NoError(t, err)
assert.Equal(t, updatedBody, summary.Content)
assert.Greater(t, summary.Updated, staleUpdated)
stepTwoBody := "### Second step summary\n\n- another step\n"
MakeRequest(t, putSummary(1, stepTwoBody, actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)
summary, err = actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 1)
require.NoError(t, err)
assert.Equal(t, stepTwoBody, summary.Content)
summaries, err := actions_model.ListActionRunJobSummaries(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, 0)
require.NoError(t, err)
require.Len(t, summaries, 2)
assert.Equal(t, int64(0), summaries[0].StepIndex)
assert.Equal(t, int64(1), summaries[1].StepIndex)
})
t.Run("invalid-content-type", func(t *testing.T) {
resp := MakeRequest(t, putSummary(0, "summary", "text/html"), http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "invalid summary content type")
})
t.Run("size-limit", func(t *testing.T) {
resp := MakeRequest(t, putSummary(0, strings.Repeat("a", actions_model.MaxJobSummarySize+1), actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "invalid summary")
})
t.Run("aggregate-size-limit", func(t *testing.T) {
require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0,
actions_model.JobSummaryContentTypeMarkdown, []byte(strings.Repeat("a", actions_model.MaxJobSummaryAggregateSize-1024))))
resp := MakeRequest(t, putSummary(1, strings.Repeat("b", 4096), actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "aggregate size exceeded")
})
t.Run("job-mismatch", func(t *testing.T) {
req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/steps/0/summary", task.Job.RunID, task.Job.ID+1), strings.NewReader("summary")).
AddTokenAuth(runnerToken).
SetHeader("Content-Type", actions_model.JobSummaryContentTypeMarkdown)
resp := MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "job_id mismatch")
})
t.Run("run-mismatch", func(t *testing.T) {
req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/steps/0/summary", task.Job.RunID+1, task.Job.ID), strings.NewReader("summary")).
AddTokenAuth(runnerToken).
SetHeader("Content-Type", actions_model.JobSummaryContentTypeMarkdown)
resp := MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "run-id does not match")
})
t.Run("invalid-step-index", func(t *testing.T) {
resp := MakeRequest(t, putSummary(-1, "summary", actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "invalid step_index")
})
t.Run("step-index-mismatch", func(t *testing.T) {
resp := MakeRequest(t, putSummary(999, "summary", actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "step_index mismatch")
})
t.Run("empty-body-clears", func(t *testing.T) {
MakeRequest(t, putSummary(0, "### keep me", actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)
MakeRequest(t, putSummary(0, "", actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)
_, err := actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0)
require.ErrorIs(t, err, util.ErrNotExist)
})
}
func TestActionsArtifactUploadSingleFile(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
@@ -39,8 +198,7 @@ func TestActionsArtifactUploadSingleFile(t *testing.T) {
Name: "artifact",
}).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
var uploadResp uploadArtifactResponse
DecodeJSON(t, resp, &uploadResp)
uploadResp := DecodeJSON(t, resp, &uploadArtifactResponse{})
assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
// get upload url
@@ -120,8 +278,7 @@ func TestActionsArtifactDownload(t *testing.T) {
req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
var listResp listArtifactsResponse
DecodeJSON(t, resp, &listResp)
listResp := DecodeJSON(t, resp, &listArtifactsResponse{})
assert.Equal(t, int64(2), listResp.Count)
// Return list might be in any order. Get one file.
@@ -137,12 +294,11 @@ func TestActionsArtifactDownload(t *testing.T) {
assert.Contains(t, listResp.Value[artifactIdx].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
idx := strings.Index(listResp.Value[artifactIdx].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
url := listResp.Value[artifactIdx].FileContainerResourceURL[idx+1:] + "?itemPath=artifact-download"
url := listResp.Value[artifactIdx].FileContainerResourceURL[idx:] + "?itemPath=artifact-download"
req = NewRequest(t, "GET", url).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp = MakeRequest(t, req, http.StatusOK)
var downloadResp downloadArtifactResponse
DecodeJSON(t, resp, &downloadResp)
downloadResp := DecodeJSON(t, resp, &downloadArtifactResponse{})
assert.Len(t, downloadResp.Value, 1)
assert.Equal(t, "artifact-download/abc.txt", downloadResp.Value[0].Path)
assert.Equal(t, "file", downloadResp.Value[0].ItemType)
@@ -169,8 +325,7 @@ func TestActionsArtifactUploadMultipleFile(t *testing.T) {
Name: testArtifactName,
}).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
var uploadResp uploadArtifactResponse
DecodeJSON(t, resp, &uploadResp)
uploadResp := DecodeJSON(t, resp, &uploadArtifactResponse{})
assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
type uploadingFile struct {
@@ -222,8 +377,7 @@ func TestActionsArtifactDownloadMultiFiles(t *testing.T) {
req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
var listResp listArtifactsResponse
DecodeJSON(t, resp, &listResp)
listResp := DecodeJSON(t, resp, &listArtifactsResponse{})
assert.Equal(t, int64(2), listResp.Count)
var fileContainerResourceURL string
@@ -236,12 +390,11 @@ func TestActionsArtifactDownloadMultiFiles(t *testing.T) {
assert.Contains(t, fileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
idx := strings.Index(fileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
url := fileContainerResourceURL[idx+1:] + "?itemPath=" + testArtifactName
url := fileContainerResourceURL[idx:] + "?itemPath=" + testArtifactName
req = NewRequest(t, "GET", url).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp = MakeRequest(t, req, http.StatusOK)
var downloadResp downloadArtifactResponse
DecodeJSON(t, resp, &downloadResp)
downloadResp := DecodeJSON(t, resp, &downloadArtifactResponse{})
assert.Len(t, downloadResp.Value, 2)
downloads := [][]string{{"multi-file-download/abc.txt", "B"}, {"multi-file-download/xyz/def.txt", "C"}}
@@ -279,8 +432,7 @@ func TestActionsArtifactUploadWithRetentionDays(t *testing.T) {
RetentionDays: 9,
}).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
var uploadResp uploadArtifactResponse
DecodeJSON(t, resp, &uploadResp)
uploadResp := DecodeJSON(t, resp, &uploadArtifactResponse{})
assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
assert.Contains(t, uploadResp.FileContainerResourceURL, "?retentionDays=9")
@@ -313,16 +465,14 @@ func TestActionsArtifactOverwrite(t *testing.T) {
req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
var listResp listArtifactsResponse
DecodeJSON(t, resp, &listResp)
listResp := DecodeJSON(t, resp, &listArtifactsResponse{})
idx := strings.Index(listResp.Value[0].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
url := listResp.Value[0].FileContainerResourceURL[idx+1:] + "?itemPath=artifact-download"
url := listResp.Value[0].FileContainerResourceURL[idx:] + "?itemPath=artifact-download"
req = NewRequest(t, "GET", url).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp = MakeRequest(t, req, http.StatusOK)
var downloadResp downloadArtifactResponse
DecodeJSON(t, resp, &downloadResp)
downloadResp := DecodeJSON(t, resp, &downloadArtifactResponse{})
idx = strings.Index(downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
url = downloadResp.Value[0].ContentLocation[idx:]
@@ -340,8 +490,7 @@ func TestActionsArtifactOverwrite(t *testing.T) {
Name: "artifact-download",
}).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
var uploadResp uploadArtifactResponse
DecodeJSON(t, resp, &uploadResp)
uploadResp := DecodeJSON(t, resp, &uploadArtifactResponse{})
idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
url := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=artifact-download/abc.txt"
@@ -364,8 +513,7 @@ func TestActionsArtifactOverwrite(t *testing.T) {
req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
var listResp listArtifactsResponse
DecodeJSON(t, resp, &listResp)
listResp := DecodeJSON(t, resp, &listArtifactsResponse{})
var uploadedItem listArtifactsResponseItem
for _, item := range listResp.Value {
@@ -377,12 +525,11 @@ func TestActionsArtifactOverwrite(t *testing.T) {
assert.Equal(t, "artifact-download", uploadedItem.Name)
idx := strings.Index(uploadedItem.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
url := uploadedItem.FileContainerResourceURL[idx+1:] + "?itemPath=artifact-download"
url := uploadedItem.FileContainerResourceURL[idx:] + "?itemPath=artifact-download"
req = NewRequest(t, "GET", url).
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp = MakeRequest(t, req, http.StatusOK)
var downloadResp downloadArtifactResponse
DecodeJSON(t, resp, &downloadResp)
downloadResp := DecodeJSON(t, resp, &downloadArtifactResponse{})
idx = strings.Index(downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
url = downloadResp.Value[0].ContentLocation[idx:]
@@ -393,3 +540,259 @@ func TestActionsArtifactOverwrite(t *testing.T) {
assert.Equal(t, resp.Body.String(), body)
}
}
func TestActionRunAttemptArtifact(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
apiRepo := createActionsTestRepo(t, token, "actions-run-attempt-artifact", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
defer doAPIDeleteRepository(httpContext)(t)
runner := newMockRunner()
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
wfTreePath := ".gitea/workflows/run-attempt-artifact.yml"
wfFileContent := `name: run-attempt-artifact
on:
workflow_dispatch:
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo 'job1'
`
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+wfTreePath, wfFileContent)
createWorkflowFile(t, token, user2.Name, repo.Name, wfTreePath, opts)
urlStr := fmt.Sprintf("/%s/%s/actions/run?workflow=%s", user2.Name, repo.Name, "run-attempt-artifact.yml")
req := NewRequestWithValues(t, "POST", urlStr, map[string]string{
"ref": "refs/heads/main",
})
session.MakeRequest(t, req, http.StatusSeeOther)
t.Run("testActionRunAttemptArtifactV3", func(t *testing.T) {
testActionRunAttemptArtifactV3(t, repo, session, runner)
})
t.Run("testActionRunAttemptArtifactV4", func(t *testing.T) {
testActionRunAttemptArtifactV4(t, repo, session, runner)
})
t.Run("testPartialRerunArtifactInheritance", func(t *testing.T) {
testPartialRerunArtifactInheritance(t, user2, token, repo, session, runner)
})
})
}
func testActionRunAttemptArtifactV3(t *testing.T, repo *repo_model.Repository, session *TestSession, runner *mockRunner) {
// first run
task1 := runner.fetchTask(t)
_, job1, run := getTaskAndJobAndRunByTaskID(t, task1.Id)
require.NotZero(t, job1.RunAttemptID)
taskToken1 := task1.Context.GetFields()["gitea_runtime_token"].GetStringValue()
require.NotEmpty(t, taskToken1)
uploadTestArtifactFile(t, run.ID, taskToken1, "artifact-attempt-1", "attempt-1.txt", strings.Repeat("A", 32))
uploadTestArtifactFile(t, run.ID, taskToken1, "artifact-shared", "shared.txt", strings.Repeat("C", 32))
attempt1Names := listArtifactNamesForRun(t, run.ID, taskToken1)
assert.ElementsMatch(t, []string{"artifact-attempt-1", "artifact-shared"}, attempt1Names)
runner.execTask(t, task1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) // complete first run
// rerun
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", repo.OwnerName, repo.Name, run.ID))
session.MakeRequest(t, req, http.StatusOK)
task2 := runner.fetchTask(t)
_, job2, _ := getTaskAndJobAndRunByTaskID(t, task2.Id)
require.NotZero(t, job2.RunAttemptID)
assert.NotEqual(t, job1.RunAttemptID, job2.RunAttemptID)
taskToken2 := task2.Context.GetFields()["gitea_runtime_token"].GetStringValue()
require.NotEmpty(t, taskToken2)
uploadTestArtifactFile(t, run.ID, taskToken2, "artifact-attempt-2", "attempt-2.txt", strings.Repeat("B", 32))
uploadTestArtifactFile(t, run.ID, taskToken2, "artifact-shared", "shared.txt", strings.Repeat("D", 32))
attempt2Names := listArtifactNamesForRun(t, run.ID, taskToken2)
assert.ElementsMatch(t, []string{"artifact-attempt-2", "artifact-shared"}, attempt2Names)
assert.NotContains(t, attempt2Names, "artifact-attempt-1")
// "artifact-attempt-1" belongs to the first attempt, so the rerun token cannot access it
req = NewRequest(t, "GET", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts/%x/download_url?itemPath=artifact-attempt-1", run.ID, md5.Sum([]byte("artifact-attempt-1")))).
AddTokenAuth(taskToken2)
MakeRequest(t, req, http.StatusNotFound)
// "artifact-shared" for each attempt has different content
sharedContent1 := downloadArtifactFileContentByAttempt(t, session, repo.OwnerName, repo.Name, run.ID, "artifact-shared", 1, "shared.txt")
assert.Equal(t, strings.Repeat("C", 32), sharedContent1)
sharedContent2 := downloadArtifactFileContentByAttempt(t, session, repo.OwnerName, repo.Name, run.ID, "artifact-shared", 2, "shared.txt")
assert.Equal(t, strings.Repeat("D", 32), sharedContent2)
}
func testPartialRerunArtifactInheritance(t *testing.T, user *user_model.User, token string, repo *repo_model.Repository, session *TestSession, runner *mockRunner) {
wfTreePath := ".gitea/workflows/partial-rerun-artifact.yml"
wfFileContent := `name: partial-rerun-artifact
on:
workflow_dispatch:
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo 'job1'
job2:
runs-on: ubuntu-latest
needs: job1
steps:
- run: echo 'job2'
`
opts := getWorkflowCreateFileOptions(user, repo.DefaultBranch, "create "+wfTreePath, wfFileContent)
createWorkflowFile(t, token, user.Name, repo.Name, wfTreePath, opts)
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/actions/run?workflow=%s", repo.OwnerName, repo.Name, "partial-rerun-artifact.yml"), map[string]string{
"ref": "refs/heads/main",
})
session.MakeRequest(t, req, http.StatusSeeOther)
// job1 uploads the artifacts and succeeds
task1 := runner.fetchTask(t)
_, job1, run := getTaskAndJobAndRunByTaskID(t, task1.Id)
taskToken1 := task1.Context.GetFields()["gitea_runtime_token"].GetStringValue()
uploadTestArtifactFileV4(t, run.ID, job1.ID, taskToken1, "job1-only", strings.Repeat("A", 32))
uploadTestArtifactFileV4(t, run.ID, job1.ID, taskToken1, "job1-shared", strings.Repeat("B", 32))
// a v3 artifact is one row per file, so this one spans two rows
uploadTestArtifactFile(t, run.ID, taskToken1, "job1-v3", "a.txt", strings.Repeat("D", 32))
uploadTestArtifactFile(t, run.ID, taskToken1, "job1-v3", "b.txt", strings.Repeat("E", 32))
runner.execTask(t, task1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
// job2 fails
task2 := runner.fetchTask(t)
_, job2, _ := getTaskAndJobAndRunByTaskID(t, task2.Id)
runner.execTask(t, task2, &mockTaskOutcome{result: runnerv1.Result_RESULT_FAILURE})
// re-run only the failed job, so job1 is passed through and never uploads its artifacts again
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun-failed", repo.OwnerName, repo.Name, run.ID))
session.MakeRequest(t, req, http.StatusOK)
task3 := runner.fetchTask(t)
_, job3, _ := getTaskAndJobAndRunByTaskID(t, task3.Id)
require.Equal(t, job2.JobID, job3.JobID)
require.NotEqual(t, job2.RunAttemptID, job3.RunAttemptID)
taskToken3 := task3.Context.GetFields()["gitea_runtime_token"].GetStringValue()
// the new attempt inherits what the previous attempt uploaded
assert.ElementsMatch(t, []string{"job1-only", "job1-shared"}, listArtifactNamesForRunV4(t, run.ID, job3.ID, taskToken3))
assert.Equal(t, strings.Repeat("A", 32), downloadArtifactContentV4ByTask(t, run.ID, job3.ID, taskToken3, "job1-only"))
assert.Contains(t, listArtifactNamesForRun(t, run.ID, taskToken3), "job1-v3")
// a pending upload of this attempt must not shadow the confirmed copy it inherited
createTestArtifactV4(t, run.ID, job3.ID, taskToken3, "job1-only")
assert.Equal(t, strings.Repeat("A", 32), downloadArtifactContentV4ByTask(t, run.ID, job3.ID, taskToken3, "job1-only"))
// both rows of the inherited v3 artifact are readable
inheritedV3 := getArtifactDownloadItemsForRun(t, run.ID, taskToken3, "job1-v3")
require.Len(t, inheritedV3, 2)
assert.Equal(t, strings.Repeat("D", 32), downloadArtifactItemContent(t, taskToken3, inheritedV3[0]))
// uploading an inherited name in this attempt shadows the inherited artifact
inheritedSharedID := listArtifactIDForRunV4(t, run.ID, job3.ID, taskToken3, "job1-shared")
require.Len(t, listArtifactsByIDV4(t, run.ID, job3.ID, inheritedSharedID, taskToken3), 1)
uploadTestArtifactFileV4(t, run.ID, job3.ID, taskToken3, "job1-shared", strings.Repeat("C", 32))
assert.ElementsMatch(t, []string{"job1-only", "job1-shared"}, listArtifactNamesForRunV4(t, run.ID, job3.ID, taskToken3))
assert.Equal(t, strings.Repeat("C", 32), downloadArtifactContentV4ByTask(t, run.ID, job3.ID, taskToken3, "job1-shared"))
// a shadowed artifact is not listed by id either: a download resolves by name
assert.Empty(t, listArtifactsByIDV4(t, run.ID, job3.ID, inheritedSharedID, taskToken3))
// the shadowed v3 artifact is dropped as a whole, its b.txt row must not survive next to the new a.txt
uploadTestArtifactFile(t, run.ID, taskToken3, "job1-v3", "a.txt", strings.Repeat("F", 32))
shadowedV3 := getArtifactDownloadItemsForRun(t, run.ID, taskToken3, "job1-v3")
require.Len(t, shadowedV3, 1)
assert.Equal(t, strings.Repeat("F", 32), downloadArtifactItemContent(t, taskToken3, shadowedV3[0]))
runner.execTask(t, task3, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
}
func getArtifactDownloadItemsForRun(t *testing.T, runID int64, taskToken, artifactName string) []downloadArtifactResponseItem {
t.Helper()
req := NewRequest(t, "GET", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts/%x/download_url?itemPath=%s", runID, md5.Sum([]byte(artifactName)), artifactName)).
AddTokenAuth(taskToken)
resp := MakeRequest(t, req, http.StatusOK)
return DecodeJSON(t, resp, &downloadArtifactResponse{}).Value
}
func downloadArtifactItemContent(t *testing.T, taskToken string, item downloadArtifactResponseItem) string {
t.Helper()
idx := strings.Index(item.ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
require.NotEqual(t, -1, idx)
req := NewRequest(t, "GET", item.ContentLocation[idx:]).AddTokenAuth(taskToken)
return MakeRequest(t, req, http.StatusOK).Body.String()
}
func uploadTestArtifactFile(t *testing.T, runID int64, authToken, artifactName, fileName, content string) {
t.Helper()
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts", runID), getUploadArtifactRequest{
Type: "actions_storage",
Name: artifactName,
}).AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusOK)
uploadResp := DecodeJSON(t, resp, &uploadArtifactResponse{})
idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
uploadURL := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=" + artifactName + "/" + fileName
contentLen := strconv.Itoa(len(content))
contentMD5 := md5.Sum([]byte(content))
req = NewRequestWithBody(t, "PUT", uploadURL, strings.NewReader(content)).
AddTokenAuth(authToken).
SetHeader("Content-Range", fmt.Sprintf("bytes 0-%d/%d", len(content)-1, len(content))).
SetHeader("x-tfs-filelength", contentLen).
SetHeader("x-actions-results-md5", base64.StdEncoding.EncodeToString(contentMD5[:]))
MakeRequest(t, req, http.StatusOK)
req = NewRequest(t, "PATCH", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts?artifactName=%s", runID, artifactName)).
AddTokenAuth(authToken)
MakeRequest(t, req, http.StatusOK)
}
func listArtifactNamesForRun(t *testing.T, runID int64, taskToken string) []string {
t.Helper()
req := NewRequest(t, "GET", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts", runID)).
AddTokenAuth(taskToken)
resp := MakeRequest(t, req, http.StatusOK)
listResp := DecodeJSON(t, resp, &listArtifactsResponse{})
names := make([]string, 0, len(listResp.Value))
for _, item := range listResp.Value {
names = append(names, item.Name)
}
return names
}
func downloadArtifactFileContentByAttempt(t *testing.T, session *TestSession, owner, repo string, runID int64, artifactName string, attempt int64, fileName string) string {
t.Helper()
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/artifacts/%s?attempt=%d", owner, repo, runID, url.PathEscape(artifactName), attempt))
resp := session.MakeRequest(t, req, http.StatusOK)
zr, err := zip.NewReader(bytes.NewReader(resp.Body.Bytes()), int64(resp.Body.Len()))
require.NoError(t, err)
for _, f := range zr.File {
if f.Name != fileName {
continue
}
rc, err := f.Open()
require.NoError(t, err)
content, err := io.ReadAll(rc)
rc.Close()
require.NoError(t, err)
return string(content)
}
require.FailNowf(t, "artifact file not found", "artifact %q attempt %d does not contain file %q", artifactName, attempt, fileName)
return ""
}
@@ -13,23 +13,25 @@ import (
"io"
"mime"
"net/http"
"strconv"
"strings"
"testing"
"time"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/api/actions"
actions_service "code.gitea.io/gitea/services/actions"
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
"gitea.dev/modules/util"
"gitea.dev/routers/api/actions"
actions_service "gitea.dev/services/actions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -396,7 +398,7 @@ func TestActionsArtifactV4UploadSingleFileWithChunksOutOfOrder(t *testing.T) {
defer test.MockVariableValue(&setting.Actions.ArtifactStorage.AzureBlobConfig.ServeDirect, entry.serveDirect)()
default:
if entry.serveDirect {
t.Skip()
t.Skip("for non-serve-direct only")
}
}
// acquire artifact upload url
@@ -527,7 +529,7 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) {
defer test.MockVariableValue(&setting.Actions.ArtifactStorage.MinioConfig.ServeDirect, entry.ServeDirect)()
default:
if entry.ServeDirect {
t.Skip()
t.Skip("for non-serve-direct only")
}
}
@@ -660,10 +662,25 @@ func TestActionsArtifactV4DownloadSinglePublicApi(t *testing.T) {
body := strings.Repeat("D", 1024)
assert.Equal(t, body, resp.Body.String())
// confirm artifact can not be downloaded without query
req = NewRequestWithBody(t, "GET", blobLocation, nil)
req = NewRequestWithBody(t, "GET", blobLocation, nil).AddTokenAuth(token)
req.URL.RawQuery = ""
_ = MakeRequest(t, req, http.StatusUnauthorized)
notFoundResp := MakeRequest(t, req, http.StatusNotFound)
otherRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
require.NotEqual(t, repo.ID, otherRepo.ID)
for _, testCase := range []struct {
name string
path string
}{
{"other repository", fmt.Sprintf("/api/v1/repos/%s/actions/artifacts/%d/zip/raw", otherRepo.FullName(), listResp.Entries[0].ID)},
{"missing artifact", fmt.Sprintf("/api/v1/repos/%s/actions/artifacts/0/zip/raw", repo.FullName())},
{"missing repository", fmt.Sprintf("/api/v1/repos/%s/does-not-exist/actions/artifacts/%d/zip/raw", repo.OwnerName, listResp.Entries[0].ID)},
} {
t.Run(testCase.name, func(t *testing.T) {
invalidResp := MakeRequest(t, NewRequestWithBody(t, "GET", testCase.path, nil), http.StatusNotFound)
assert.Equal(t, notFoundResp.Body.String(), invalidResp.Body.String())
})
}
}
func TestActionsArtifactV4DownloadSinglePublicApiPrivateRepo(t *testing.T) {
@@ -701,7 +718,7 @@ func TestActionsArtifactV4DownloadSinglePublicApiPrivateRepo(t *testing.T) {
// confirm artifact can not be downloaded without query
req = NewRequestWithBody(t, "GET", blobLocation, nil)
req.URL.RawQuery = ""
_ = MakeRequest(t, req, http.StatusUnauthorized)
_ = MakeRequest(t, req, http.StatusNotFound)
}
func TestActionsArtifactV4ListAndGetPublicApi(t *testing.T) {
@@ -781,20 +798,6 @@ func TestActionsArtifactV4DownloadArtifactCorrectRepoFound(t *testing.T) {
MakeRequest(t, req, http.StatusFound)
}
func TestActionsArtifactV4DownloadRawArtifactCorrectRepoMissingSignatureUnauthorized(t *testing.T) {
defer prepareTestEnvActionsArtifacts(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)
// confirm cannot use the raw artifact endpoint even with a correct access token
req := NewRequestWithBody(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/artifacts/%d/zip/raw", repo.FullName(), 22), nil).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnauthorized)
}
func TestActionsArtifactV4Delete(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
@@ -880,3 +883,179 @@ func TestActionsArtifactV4DeletePublicApiNotAllowedReadScope(t *testing.T) {
AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
}
func testActionRunAttemptArtifactV4(t *testing.T, repo *repo_model.Repository, session *TestSession, runner *mockRunner) {
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/actions/run?workflow=%s", repo.OwnerName, repo.Name, "run-attempt-artifact.yml"), map[string]string{
"ref": "refs/heads/main",
})
session.MakeRequest(t, req, http.StatusSeeOther)
// first run
task1 := runner.fetchTask(t)
_, job1, run := getTaskAndJobAndRunByTaskID(t, task1.Id)
require.NotZero(t, job1.RunAttemptID)
taskToken1 := task1.Context.GetFields()["gitea_runtime_token"].GetStringValue()
require.NotEmpty(t, taskToken1)
uploadTestArtifactFileV4(t, run.ID, job1.ID, taskToken1, "artifact-attempt-1", strings.Repeat("A", 32))
uploadTestArtifactFileV4(t, run.ID, job1.ID, taskToken1, "artifact-shared", strings.Repeat("C", 32))
attempt1Names := listArtifactNamesForRunV4(t, run.ID, job1.ID, taskToken1)
assert.ElementsMatch(t, []string{"artifact-attempt-1", "artifact-shared"}, attempt1Names)
runner.execTask(t, task1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
// rerun
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", repo.OwnerName, repo.Name, run.ID))
session.MakeRequest(t, req, http.StatusOK)
task2 := runner.fetchTask(t)
_, job2, _ := getTaskAndJobAndRunByTaskID(t, task2.Id)
require.NotZero(t, job2.RunAttemptID)
assert.NotEqual(t, job1.RunAttemptID, job2.RunAttemptID)
taskToken2 := task2.Context.GetFields()["gitea_runtime_token"].GetStringValue()
require.NotEmpty(t, taskToken2)
uploadTestArtifactFileV4(t, run.ID, job2.ID, taskToken2, "artifact-attempt-2", strings.Repeat("B", 32))
uploadTestArtifactFileV4(t, run.ID, job2.ID, taskToken2, "artifact-shared", strings.Repeat("D", 32))
attempt2Names := listArtifactNamesForRunV4(t, run.ID, job2.ID, taskToken2)
assert.ElementsMatch(t, []string{"artifact-attempt-2", "artifact-shared"}, attempt2Names)
assert.NotContains(t, attempt2Names, "artifact-attempt-1")
// "artifact-attempt-1" belongs to the first attempt, so the rerun token cannot access it
req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL", toProtoJSON(&actions.GetSignedArtifactURLRequest{
Name: "artifact-attempt-1",
WorkflowRunBackendId: strconv.FormatInt(run.ID, 10),
WorkflowJobRunBackendId: strconv.FormatInt(job2.ID, 10),
})).AddTokenAuth(taskToken2)
MakeRequest(t, req, http.StatusNotFound)
// the run-scoped repo API should list finalized v4 artifacts from all attempts
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/artifacts", repo.OwnerName, repo.Name, run.ID))
resp := session.MakeRequest(t, req, http.StatusOK)
var runArtifactsResp api.ActionArtifactsResponse
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &runArtifactsResp))
require.Len(t, runArtifactsResp.Entries, 4)
runArtifactNames := make([]string, 0, len(runArtifactsResp.Entries))
for _, artifact := range runArtifactsResp.Entries {
runArtifactNames = append(runArtifactNames, artifact.Name)
}
assert.ElementsMatch(t, []string{"artifact-attempt-1", "artifact-shared", "artifact-attempt-2", "artifact-shared"}, runArtifactNames)
// the result should contain 2 artifacts when query by name=artifact-shared
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d/artifacts?name=artifact-shared", repo.OwnerName, repo.Name, run.ID))
resp = session.MakeRequest(t, req, http.StatusOK)
var sharedArtifactsResp api.ActionArtifactsResponse
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &sharedArtifactsResp))
require.Len(t, sharedArtifactsResp.Entries, 2)
assert.Equal(t, strings.Repeat("C", 32), downloadRepoArtifactV4Content(t, session, sharedArtifactsResp.Entries[0].ArchiveDownloadURL))
assert.Equal(t, strings.Repeat("D", 32), downloadRepoArtifactV4Content(t, session, sharedArtifactsResp.Entries[1].ArchiveDownloadURL))
}
func downloadArtifactContentV4ByTask(t *testing.T, runID, jobID int64, taskToken, artifactName string) string {
t.Helper()
req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL", toProtoJSON(&actions.GetSignedArtifactURLRequest{
Name: artifactName,
WorkflowRunBackendId: strconv.FormatInt(runID, 10),
WorkflowJobRunBackendId: strconv.FormatInt(jobID, 10),
})).AddTokenAuth(taskToken)
resp := MakeRequest(t, req, http.StatusOK)
var urlResp actions.GetSignedArtifactURLResponse
require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &urlResp))
require.NotEmpty(t, urlResp.SignedUrl)
return MakeRequest(t, NewRequest(t, "GET", urlResp.SignedUrl), http.StatusOK).Body.String()
}
// createTestArtifactV4 only creates the artifact record, leaving it pending until it is uploaded and finalized
func createTestArtifactV4(t *testing.T, runID, jobID int64, authToken, artifactName string) string {
t.Helper()
req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{
Version: 4,
Name: artifactName,
WorkflowRunBackendId: strconv.FormatInt(runID, 10),
WorkflowJobRunBackendId: strconv.FormatInt(jobID, 10),
MimeType: wrapperspb.String("application/zip"),
})).AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusOK)
var createResp actions.CreateArtifactResponse
require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &createResp))
require.True(t, createResp.Ok)
return createResp.SignedUploadUrl
}
func uploadTestArtifactFileV4(t *testing.T, runID, jobID int64, authToken, artifactName, content string) {
t.Helper()
signedUploadURL := createTestArtifactV4(t, runID, jobID, authToken, artifactName)
req := NewRequestWithBody(t, "PUT", signedUploadURL+"&comp=appendBlock", strings.NewReader(content))
MakeRequest(t, req, http.StatusCreated)
sum := sha256.Sum256([]byte(content))
req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact", toProtoJSON(&actions.FinalizeArtifactRequest{
Name: artifactName,
Size: int64(len(content)),
Hash: wrapperspb.String("sha256:" + hex.EncodeToString(sum[:])),
WorkflowRunBackendId: strconv.FormatInt(runID, 10),
WorkflowJobRunBackendId: strconv.FormatInt(jobID, 10),
})).AddTokenAuth(authToken)
resp := MakeRequest(t, req, http.StatusOK)
var finalizeResp actions.FinalizeArtifactResponse
require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp))
require.True(t, finalizeResp.Ok)
}
func listArtifactsForRunV4(t *testing.T, taskToken string, req *actions.ListArtifactsRequest) []*actions.ListArtifactsResponse_MonolithArtifact {
t.Helper()
httpReq := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts", toProtoJSON(req)).AddTokenAuth(taskToken)
resp := MakeRequest(t, httpReq, http.StatusOK)
var listResp actions.ListArtifactsResponse
require.NoError(t, protojson.Unmarshal(resp.Body.Bytes(), &listResp))
return listResp.Artifacts
}
func listArtifactNamesForRunV4(t *testing.T, runID, jobID int64, taskToken string) []string {
t.Helper()
artifacts := listArtifactsForRunV4(t, taskToken, &actions.ListArtifactsRequest{
WorkflowRunBackendId: strconv.FormatInt(runID, 10),
WorkflowJobRunBackendId: strconv.FormatInt(jobID, 10),
})
names := make([]string, 0, len(artifacts))
for _, item := range artifacts {
names = append(names, item.Name)
}
return names
}
func listArtifactIDForRunV4(t *testing.T, runID, jobID int64, taskToken, artifactName string) int64 {
t.Helper()
artifacts := listArtifactsForRunV4(t, taskToken, &actions.ListArtifactsRequest{
NameFilter: wrapperspb.String(artifactName),
WorkflowRunBackendId: strconv.FormatInt(runID, 10),
WorkflowJobRunBackendId: strconv.FormatInt(jobID, 10),
})
require.Len(t, artifacts, 1)
return artifacts[0].DatabaseId
}
func listArtifactsByIDV4(t *testing.T, runID, jobID, artifactID int64, taskToken string) []*actions.ListArtifactsResponse_MonolithArtifact {
t.Helper()
return listArtifactsForRunV4(t, taskToken, &actions.ListArtifactsRequest{
IdFilter: wrapperspb.Int64(artifactID),
WorkflowRunBackendId: strconv.FormatInt(runID, 10),
WorkflowJobRunBackendId: strconv.FormatInt(jobID, 10),
})
}
func downloadRepoArtifactV4Content(t *testing.T, session *TestSession, archiveDownloadURL string) string {
t.Helper()
req := NewRequest(t, "GET", archiveDownloadURL)
resp := session.MakeRequest(t, req, http.StatusFound)
req = NewRequest(t, "GET", resp.Header().Get("Location"))
resp = MakeRequest(t, req, http.StatusOK)
return resp.Body.String()
}
@@ -7,10 +7,10 @@ import (
"net/http"
"testing"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/tests"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -20,8 +20,7 @@ func testActionUserSignIn(t *testing.T) {
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
resp := MakeRequest(t, req, http.StatusOK)
var u api.User
DecodeJSON(t, resp, &u)
u := DecodeJSON(t, resp, &api.User{})
assert.Equal(t, "gitea-actions", u.UserName)
}
@@ -9,23 +9,41 @@ import (
"slices"
"testing"
actions_model "code.gitea.io/gitea/models/actions"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/json"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil"
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"
api "gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAPIActionsGetWorkflowRun(t *testing.T) {
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("DeleteRunGeneral", testAPIActionsDeleteRunGeneral)
t.Run("RerunWorkflowRun", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
testAPIActionsRerunWorkflowRun(t)
})
t.Run("RerunWorkflowJob", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
testAPIActionsRerunWorkflowJob(t)
})
}
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)
@@ -56,13 +74,9 @@ func TestAPIActionsGetWorkflowRun(t *testing.T) {
})
require.NoError(t, err)
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs", repo.FullName())).
AddTokenAuth(token)
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs", repo.FullName())).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var jobList api.ActionWorkflowJobsResponse
err = json.Unmarshal(resp.Body.Bytes(), &jobList)
require.NoError(t, err)
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")
@@ -72,9 +86,7 @@ func TestAPIActionsGetWorkflowRun(t *testing.T) {
})
}
func TestAPIActionsGetWorkflowJob(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
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)
@@ -91,9 +103,7 @@ func TestAPIActionsGetWorkflowJob(t *testing.T) {
MakeRequest(t, req, http.StatusNotFound)
}
func TestAPIActionsDeleteRunCheckPermission(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
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)
@@ -101,9 +111,7 @@ func TestAPIActionsDeleteRunCheckPermission(t *testing.T) {
testAPIActionsDeleteRun(t, repo, token, http.StatusNotFound)
}
func TestAPIActionsDeleteRun(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
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)
@@ -118,9 +126,7 @@ func TestAPIActionsDeleteRun(t *testing.T) {
testAPIActionsDeleteRun(t, repo, token, http.StatusNotFound)
}
func TestAPIActionsDeleteRunRunning(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
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)
@@ -138,22 +144,17 @@ func testAPIActionsDeleteRun(t *testing.T, repo *repo_model.Repository, token st
}
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)
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/artifacts", repo.FullName())).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var listResp api.ActionArtifactsResponse
err := json.Unmarshal(resp.Body.Bytes(), &listResp)
assert.NoError(t, err)
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)
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/tasks", repo.FullName())).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var listResp api.ActionTaskResponse
err := json.Unmarshal(resp.Body.Bytes(), &listResp)
assert.NoError(t, err)
listResp := DecodeJSON(t, resp, &api.ActionTaskResponse{})
findTask1 := false
findTask2 := false
for _, entry := range listResp.Entries {
@@ -170,9 +171,7 @@ func testAPIActionsDeleteRunListTasks(t *testing.T, repo *repo_model.Repository,
assert.Equal(t, expected, findTask2)
}
func TestAPIActionsRerunWorkflowRun(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
func testAPIActionsRerunWorkflowRun(t *testing.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})
@@ -192,13 +191,10 @@ func TestAPIActionsRerunWorkflowRun(t *testing.T) {
readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
t.Run("Success", func(t *testing.T) {
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/rerun", repo.FullName())).
AddTokenAuth(writeToken)
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{})
var rerunResp api.ActionWorkflowRun
err := json.Unmarshal(resp.Body.Bytes(), &rerunResp)
require.NoError(t, err)
assert.Equal(t, int64(795), rerunResp.ID)
assert.Equal(t, "queued", rerunResp.Status)
assert.Equal(t, "c2d72f548424103f01ee1dc02889c1e2bff816b0", rerunResp.HeadSha)
@@ -208,15 +204,18 @@ func TestAPIActionsRerunWorkflowRun(t *testing.T) {
assert.Equal(t, actions_model.StatusWaiting, run.Status)
assert.Equal(t, timeutil.TimeStamp(0), run.Started)
assert.Equal(t, timeutil.TimeStamp(0), run.Stopped)
job198, err := actions_model.GetRunJobByRunAndID(t.Context(), 795, 198)
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, err := actions_model.GetRunJobByRunAndID(t.Context(), 795, 199)
require.NoError(t, err)
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)
})
@@ -233,9 +232,7 @@ func TestAPIActionsRerunWorkflowRun(t *testing.T) {
})
}
func TestAPIActionsRerunWorkflowJob(t *testing.T) {
defer prepareTestEnvActionsArtifacts(t)()
func testAPIActionsRerunWorkflowJob(t *testing.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})
@@ -255,29 +252,32 @@ func TestAPIActionsRerunWorkflowJob(t *testing.T) {
readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
t.Run("Success", func(t *testing.T) {
req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/runs/795/jobs/199/rerun", repo.FullName())).
AddTokenAuth(writeToken)
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{})
var rerunResp api.ActionWorkflowJob
err := json.Unmarshal(resp.Body.Bytes(), &rerunResp)
require.NoError(t, err)
assert.Equal(t, int64(199), rerunResp.ID)
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)
job198, err := actions_model.GetRunJobByRunAndID(t.Context(), 795, 198)
latestAttempt, hasLatestAttempt, err := run.GetLatestAttempt(t.Context())
require.NoError(t, err)
assert.Equal(t, actions_model.StatusSuccess, job198.Status)
assert.Equal(t, int64(53), job198.TaskID)
require.True(t, hasLatestAttempt)
job199, err := actions_model.GetRunJobByRunAndID(t.Context(), 795, 199)
require.NoError(t, err)
assert.Equal(t, actions_model.StatusWaiting, job199.Status)
assert.Equal(t, int64(0), job199.TaskID)
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("ForbiddenWithoutWriteScope", func(t *testing.T) {
@@ -292,3 +292,125 @@ func TestAPIActionsRerunWorkflowJob(t *testing.T) {
MakeRequest(t, req, http.StatusNotFound)
})
}
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")
}
}
// 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")
}
}
@@ -9,9 +9,9 @@ import (
"slices"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
auth_model "gitea.dev/models/auth"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -36,16 +36,14 @@ func testActionsRunnerAdmin(t *testing.T) {
token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin)
req := NewRequest(t, "POST", "/api/v1/admin/actions/runners/registration-token").AddTokenAuth(token)
tokenResp := MakeRequest(t, req, http.StatusOK)
var registrationToken struct {
registrationToken := DecodeJSON(t, tokenResp, &struct {
Token string `json:"token"`
}
DecodeJSON(t, tokenResp, &registrationToken)
}{})
assert.NotEmpty(t, registrationToken.Token)
req = NewRequest(t, "GET", "/api/v1/admin/actions/runners").AddTokenAuth(token)
runnerListResp := MakeRequest(t, req, http.StatusOK)
runnerList := api.ActionRunnersResponse{}
DecodeJSON(t, runnerListResp, &runnerList)
runnerList := DecodeJSON(t, runnerListResp, &api.ActionRunnersResponse{})
idx := slices.IndexFunc(runnerList.Entries, func(e *api.ActionRunner) bool { return e.ID == 34349 })
require.NotEqual(t, -1, idx)
@@ -63,8 +61,7 @@ func testActionsRunnerAdmin(t *testing.T) {
MakeRequest(t, req, http.StatusOK)
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/admin/actions/runners/%d", expectedRunner.ID)).AddTokenAuth(token)
runnerResp := MakeRequest(t, req, http.StatusOK)
disabledRunner := api.ActionRunner{}
DecodeJSON(t, runnerResp, &disabledRunner)
disabledRunner := DecodeJSON(t, runnerResp, &api.ActionRunner{})
assert.True(t, disabledRunner.Disabled)
req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/admin/actions/runners/%d", expectedRunner.ID), false).AddTokenAuth(token)
@@ -78,8 +75,7 @@ func testActionsRunnerAdmin(t *testing.T) {
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/admin/actions/runners/%d", runnerEntry.ID)).AddTokenAuth(token)
runnerResp = MakeRequest(t, req, http.StatusOK)
runner := api.ActionRunner{}
DecodeJSON(t, runnerResp, &runner)
runner := DecodeJSON(t, runnerResp, &api.ActionRunner{})
assert.Equal(t, runnerEntry.Name, runner.Name)
assert.Equal(t, runnerEntry.ID, runner.ID)
@@ -102,16 +98,14 @@ func testActionsRunnerUser(t *testing.T) {
token := getUserToken(t, userUsername, auth_model.AccessTokenScopeWriteUser)
req := NewRequest(t, "POST", "/api/v1/user/actions/runners/registration-token").AddTokenAuth(token)
tokenResp := MakeRequest(t, req, http.StatusOK)
var registrationToken struct {
registrationToken := DecodeJSON(t, tokenResp, &struct {
Token string `json:"token"`
}
DecodeJSON(t, tokenResp, &registrationToken)
}{})
assert.NotEmpty(t, registrationToken.Token)
req = NewRequest(t, "GET", "/api/v1/user/actions/runners").AddTokenAuth(token)
runnerListResp := MakeRequest(t, req, http.StatusOK)
runnerList := api.ActionRunnersResponse{}
DecodeJSON(t, runnerListResp, &runnerList)
runnerList := DecodeJSON(t, runnerListResp, &api.ActionRunnersResponse{})
assert.Len(t, runnerList.Entries, 1)
assert.Equal(t, "runner_to_be_deleted-user", runnerList.Entries[0].Name)
@@ -126,8 +120,7 @@ func testActionsRunnerUser(t *testing.T) {
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
runnerResp := MakeRequest(t, req, http.StatusOK)
runner := api.ActionRunner{}
DecodeJSON(t, runnerResp, &runner)
runner := DecodeJSON(t, runnerResp, &api.ActionRunner{})
assert.Equal(t, "runner_to_be_deleted-user", runner.Name)
assert.Equal(t, int64(34346), runner.ID)
@@ -143,7 +136,7 @@ func testActionsRunnerUser(t *testing.T) {
MakeRequest(t, req, http.StatusOK)
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
runnerResp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, runnerResp, &runner)
runner = DecodeJSON(t, runnerResp, &api.ActionRunner{})
assert.True(t, runner.Disabled)
req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID), false).AddTokenAuth(token)
@@ -170,8 +163,7 @@ func testActionsRunnerOwner(t *testing.T) {
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", 34347)).AddTokenAuth(token)
runnerResp := MakeRequest(t, req, http.StatusOK)
runner := api.ActionRunner{}
DecodeJSON(t, runnerResp, &runner)
runner := DecodeJSON(t, runnerResp, &api.ActionRunner{})
assert.Equal(t, "runner_to_be_deleted-org", runner.Name)
assert.Equal(t, int64(34347), runner.ID)
@@ -187,16 +179,14 @@ func testActionsRunnerOwner(t *testing.T) {
token := getUserToken(t, userUsername, auth_model.AccessTokenScopeWriteOrganization)
req := NewRequest(t, "POST", "/api/v1/orgs/org3/actions/runners/registration-token").AddTokenAuth(token)
tokenResp := MakeRequest(t, req, http.StatusOK)
var registrationToken struct {
registrationToken := DecodeJSON(t, tokenResp, &struct {
Token string `json:"token"`
}
DecodeJSON(t, tokenResp, &registrationToken)
}{})
assert.NotEmpty(t, registrationToken.Token)
req = NewRequest(t, "GET", "/api/v1/orgs/org3/actions/runners").AddTokenAuth(token)
runnerListResp := MakeRequest(t, req, http.StatusOK)
runnerList := api.ActionRunnersResponse{}
DecodeJSON(t, runnerListResp, &runnerList)
runnerList := DecodeJSON(t, runnerListResp, &api.ActionRunnersResponse{})
idx := slices.IndexFunc(runnerList.Entries, func(e *api.ActionRunner) bool { return e.ID == 34347 })
require.NotEqual(t, -1, idx)
@@ -215,8 +205,7 @@ func testActionsRunnerOwner(t *testing.T) {
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID)).AddTokenAuth(token)
runnerResp := MakeRequest(t, req, http.StatusOK)
runner := api.ActionRunner{}
DecodeJSON(t, runnerResp, &runner)
runner := DecodeJSON(t, runnerResp, &api.ActionRunner{})
assert.Equal(t, "runner_to_be_deleted-org", runner.Name)
assert.Equal(t, int64(34347), runner.ID)
@@ -232,7 +221,7 @@ func testActionsRunnerOwner(t *testing.T) {
MakeRequest(t, req, http.StatusOK)
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID)).AddTokenAuth(token)
runnerResp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, runnerResp, &runner)
runner = DecodeJSON(t, runnerResp, &api.ActionRunner{})
assert.True(t, runner.Disabled)
req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID), false).AddTokenAuth(token)
@@ -311,8 +300,7 @@ func testActionsRunnerRepo(t *testing.T) {
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", 34348)).AddTokenAuth(token)
runnerResp := MakeRequest(t, req, http.StatusOK)
runner := api.ActionRunner{}
DecodeJSON(t, runnerResp, &runner)
runner := DecodeJSON(t, runnerResp, &api.ActionRunner{})
assert.Equal(t, "runner_to_be_deleted-repo1", runner.Name)
assert.Equal(t, int64(34348), runner.ID)
@@ -328,16 +316,14 @@ func testActionsRunnerRepo(t *testing.T) {
token := getUserToken(t, userUsername, auth_model.AccessTokenScopeWriteRepository)
req := NewRequest(t, "POST", "/api/v1/repos/user2/repo1/actions/runners/registration-token").AddTokenAuth(token)
tokenResp := MakeRequest(t, req, http.StatusOK)
var registrationToken struct {
registrationToken := DecodeJSON(t, tokenResp, &struct {
Token string `json:"token"`
}
DecodeJSON(t, tokenResp, &registrationToken)
}{})
assert.NotEmpty(t, registrationToken.Token)
req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/actions/runners").AddTokenAuth(token)
runnerListResp := MakeRequest(t, req, http.StatusOK)
runnerList := api.ActionRunnersResponse{}
DecodeJSON(t, runnerListResp, &runnerList)
runnerList := DecodeJSON(t, runnerListResp, &api.ActionRunnersResponse{})
assert.Len(t, runnerList.Entries, 1)
assert.Equal(t, "runner_to_be_deleted-repo1", runnerList.Entries[0].Name)
@@ -352,8 +338,7 @@ func testActionsRunnerRepo(t *testing.T) {
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
runnerResp := MakeRequest(t, req, http.StatusOK)
runner := api.ActionRunner{}
DecodeJSON(t, runnerResp, &runner)
runner := DecodeJSON(t, runnerResp, &api.ActionRunner{})
assert.Equal(t, "runner_to_be_deleted-repo1", runner.Name)
assert.Equal(t, int64(34348), runner.ID)
@@ -369,7 +354,7 @@ func testActionsRunnerRepo(t *testing.T) {
MakeRequest(t, req, http.StatusOK)
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
runnerResp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, runnerResp, &runner)
runner = DecodeJSON(t, runnerResp, &api.ActionRunner{})
assert.True(t, runner.Disabled)
req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID), false).AddTokenAuth(token)
@@ -8,11 +8,11 @@ import (
"strings"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -36,8 +36,7 @@ func TestAPIAdminOrgCreate(t *testing.T) {
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var apiOrg api.Organization
DecodeJSON(t, resp, &apiOrg)
apiOrg := DecodeJSON(t, resp, &api.Organization{})
assert.Equal(t, org.UserName, apiOrg.Name)
assert.Equal(t, org.FullName, apiOrg.FullName)
@@ -9,15 +9,16 @@ import (
"testing"
"time"
asymkey_model "code.gitea.io/gitea/models/asymkey"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/glob"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
asymkey_model "gitea.dev/models/asymkey"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/glob"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -36,8 +37,7 @@ func TestAPIAdminCreateAndDeleteSSHKey(t *testing.T) {
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var newPublicKey api.PublicKey
DecodeJSON(t, resp, &newPublicKey)
newPublicKey := DecodeJSON(t, resp, &api.PublicKey{})
unittest.AssertExistsAndLoadBean(t, &asymkey_model.PublicKey{
ID: newPublicKey.ID,
Name: newPublicKey.Title,
@@ -73,8 +73,7 @@ func TestAPIAdminDeleteUnauthorizedKey(t *testing.T) {
"title": "test-key",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var newPublicKey api.PublicKey
DecodeJSON(t, resp, &newPublicKey)
newPublicKey := DecodeJSON(t, resp, &api.PublicKey{})
token = getUserToken(t, normalUsername, auth_model.AccessTokenScopeAll)
req = NewRequestf(t, "DELETE", "/api/v1/admin/users/%s/keys/%d", adminUsername, newPublicKey.ID).
@@ -91,8 +90,7 @@ func TestAPISudoUser(t *testing.T) {
req := NewRequest(t, "GET", "/api/v1/user?sudo="+normalUsername).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var user api.User
DecodeJSON(t, resp, &user)
user := DecodeJSON(t, resp, &api.User{})
assert.Equal(t, normalUsername, user.UserName)
}
@@ -116,8 +114,7 @@ func TestAPIListUsers(t *testing.T) {
req := NewRequest(t, "GET", "/api/v1/admin/users").
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var users []api.User
DecodeJSON(t, resp, &users)
users := DecodeJSON(t, resp, []api.User{})
found := false
for _, user := range users {
@@ -207,12 +204,7 @@ func TestAPIEditUser(t *testing.T) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{LoginName: "user2"})
assert.Equal(t, fullNameToChange, user2.FullName)
empty := ""
req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{
LoginName: "user2",
SourceID: 0,
Email: &empty,
}).AddTokenAuth(token)
req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{SourceID: 0, Email: new("")}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusBadRequest)
errMap := make(map[string]any)
@@ -221,14 +213,7 @@ func TestAPIEditUser(t *testing.T) {
user2 = unittest.AssertExistsAndLoadBean(t, &user_model.User{LoginName: "user2"})
assert.False(t, user2.IsRestricted)
bTrue := true
req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{
// required
LoginName: "user2",
SourceID: 0,
// to change
Restricted: &bTrue,
}).AddTokenAuth(token)
req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{Restricted: new(true)}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
user2 = unittest.AssertExistsAndLoadBean(t, &user_model.User{LoginName: "user2"})
assert.True(t, user2.IsRestricted)
@@ -306,8 +291,7 @@ func TestAPICron(t *testing.T) {
assert.Equal(t, "29", resp.Header().Get("X-Total-Count"))
var crons []api.Cron
DecodeJSON(t, resp, &crons)
crons := DecodeJSON(t, resp, []api.Cron{})
assert.Len(t, crons, 29)
})
@@ -328,8 +312,7 @@ func TestAPICron(t *testing.T) {
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var crons []api.Cron
DecodeJSON(t, resp, &crons)
crons := DecodeJSON(t, resp, []api.Cron{})
for _, cron := range crons {
if cron.Name == "archive_cleanup" {
@@ -341,11 +324,7 @@ func TestAPICron(t *testing.T) {
func TestAPICreateUser_NotAllowedEmailDomain(t *testing.T) {
defer tests.PrepareTestEnv(t)()
setting.Service.EmailDomainAllowList = []glob.Glob{glob.MustCompile("example.org")}
defer func() {
setting.Service.EmailDomainAllowList = []glob.Glob{}
}()
defer test.MockVariableValue(&setting.Service.EmailDomainAllowList, []glob.Glob{glob.MustCompile("example.org")})()
adminUsername := "user1"
token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin)
@@ -366,32 +345,18 @@ func TestAPICreateUser_NotAllowedEmailDomain(t *testing.T) {
func TestAPIEditUser_NotAllowedEmailDomain(t *testing.T) {
defer tests.PrepareTestEnv(t)()
setting.Service.EmailDomainAllowList = []glob.Glob{glob.MustCompile("example.org")}
defer func() {
setting.Service.EmailDomainAllowList = []glob.Glob{}
}()
defer test.MockVariableValue(&setting.Service.EmailDomainAllowList, []glob.Glob{glob.MustCompile("example.org")})()
adminUsername := "user1"
token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin)
urlStr := "/api/v1/admin/users/" + "user2"
urlStr := "/api/v1/admin/users/user2"
newEmail := "user2@example1.com"
req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{
LoginName: "user2",
SourceID: 0,
Email: &newEmail,
}).AddTokenAuth(token)
req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{Email: new("user2@example1.com")}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusBadRequest)
errMap := make(map[string]string)
assert.NoError(t, json.Unmarshal(resp.Body.Bytes(), &errMap))
assert.Equal(t, "the domain of user email user2@example1.com conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", errMap["message"])
originalEmail := "user2@example.org"
req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{
LoginName: "user2",
SourceID: 0,
Email: &originalEmail,
}).AddTokenAuth(token)
req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{Email: new("user2@example.org")}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
}
@@ -7,7 +7,7 @@ import (
"net/http"
"testing"
"code.gitea.io/gitea/tests"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -11,11 +11,11 @@ import (
"net/url"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -30,8 +30,7 @@ func testAPIGetBranch(t *testing.T, branchName string, exists bool) {
return
}
assert.Equal(t, http.StatusOK, resp.Code)
var branch api.Branch
DecodeJSON(t, resp, &branch)
branch := DecodeJSON(t, resp, &api.Branch{})
assert.Equal(t, branchName, branch.Name)
assert.True(t, branch.UserCanPush)
assert.True(t, branch.UserCanMerge)
@@ -44,10 +43,9 @@ func testAPIGetBranchProtection(t *testing.T, branchName string, expectedHTTPSta
resp := MakeRequest(t, req, expectedHTTPStatus)
if resp.Code == http.StatusOK {
var branchProtection api.BranchProtection
DecodeJSON(t, resp, &branchProtection)
branchProtection := DecodeJSON(t, resp, &api.BranchProtection{})
assert.Equal(t, branchName, branchProtection.RuleName)
return &branchProtection
return branchProtection
}
return nil
}
@@ -60,8 +58,7 @@ func testAPICreateBranchProtection(t *testing.T, branchName string, expectedPrio
resp := MakeRequest(t, req, expectedHTTPStatus)
if resp.Code == http.StatusCreated {
var branchProtection api.BranchProtection
DecodeJSON(t, resp, &branchProtection)
branchProtection := DecodeJSON(t, resp, &api.BranchProtection{})
assert.Equal(t, branchName, branchProtection.RuleName)
assert.EqualValues(t, expectedPriority, branchProtection.Priority)
}
@@ -74,8 +71,7 @@ func testAPIEditBranchProtection(t *testing.T, branchName string, body *api.Bran
resp := MakeRequest(t, req, expectedHTTPStatus)
if resp.Code == http.StatusOK {
var branchProtection api.BranchProtection
DecodeJSON(t, resp, &branchProtection)
branchProtection := DecodeJSON(t, resp, &api.BranchProtection{})
assert.Equal(t, branchName, branchProtection.RuleName)
}
}
@@ -179,8 +175,7 @@ func testAPICreateBranch(t testing.TB, session *TestSession, user, repo, oldBran
}).AddTokenAuth(token)
resp := MakeRequest(t, req, status)
var branch api.Branch
DecodeJSON(t, resp, &branch)
branch := DecodeJSON(t, resp, &api.Branch{})
if resp.Result().StatusCode == http.StatusCreated {
assert.Equal(t, newBranch, branch.Name)
@@ -353,7 +348,7 @@ func TestAPIUpdateBranchReference(t *testing.T) {
func testAPIRenameBranch(t *testing.T, doerName, ownerName, repoName, from, to string, expectedHTTPStatus int) *httptest.ResponseRecorder {
token := getUserToken(t, doerName, auth_model.AccessTokenScopeWriteRepository)
req := NewRequestWithJSON(t, "PATCH", "api/v1/repos/"+ownerName+"/"+repoName+"/branches/"+from, &api.RenameBranchRepoOption{
req := NewRequestWithJSON(t, "PATCH", "/api/v1/repos/"+ownerName+"/"+repoName+"/branches/"+from, &api.RenameBranchRepoOption{
Name: to,
}).AddTokenAuth(token)
return MakeRequest(t, req, expectedHTTPStatus)
@@ -361,7 +356,11 @@ func testAPIRenameBranch(t *testing.T, doerName, ownerName, repoName, from, to s
func TestAPIBranchProtection(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("Basic", testAPIBranchProtectionBasic)
t.Run("BypassAllowlistValidation", testAPIBranchProtectionBypassAllowlistValidation)
}
func testAPIBranchProtectionBasic(t *testing.T) {
// Can create branch protection on branch that not exist
testAPICreateBranchProtection(t, "non-existing/branch", 1, http.StatusCreated)
testAPIGetBranchProtection(t, "non-existing/branch", http.StatusOK)
@@ -411,6 +410,35 @@ func TestAPIBranchProtection(t *testing.T) {
testAPIDeleteBranch(t, "no-such-branch", http.StatusNotFound) // non-existing branch, not exist in git or DB
}
func testAPIBranchProtectionBypassAllowlistValidation(t *testing.T) {
token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteRepository)
t.Run("IgnoreInvalidBypassUsernamesWhenDisabled", func(t *testing.T) {
ruleName := "bypass-disabled-invalid-user"
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/branch_protections", &api.CreateBranchProtectionOption{
RuleName: ruleName,
EnableBypassAllowlist: false,
BypassAllowlistUsernames: []string{"nonexistent-user"},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
testAPIDeleteBranchProtection(t, ruleName, http.StatusNoContent)
})
t.Run("IgnoreInvalidBypassTeamsWhenDisabled", func(t *testing.T) {
ruleName := "bypass-disabled-invalid-team"
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/org3/repo3/branch_protections", &api.CreateBranchProtectionOption{
RuleName: ruleName,
EnableBypassAllowlist: false,
BypassAllowlistTeams: []string{"nonexistent-team"},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
deleteReq := NewRequestf(t, "DELETE", "/api/v1/repos/org3/repo3/branch_protections/%s", ruleName).
AddTokenAuth(token)
MakeRequest(t, deleteReq, http.StatusNoContent)
})
}
func TestAPICreateBranchWithSyncBranches(t *testing.T) {
defer tests.PrepareTestEnv(t)()
@@ -10,14 +10,14 @@ import (
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/services/convert"
"code.gitea.io/gitea/tests"
auth_model "gitea.dev/models/auth"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"gitea.dev/services/convert"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -50,8 +50,7 @@ func TestAPIGetCommentAttachment(t *testing.T) {
AddTokenAuth(token)
resp := session.MakeRequest(t, req, http.StatusOK)
var apiAttachment api.Attachment
DecodeJSON(t, resp, &apiAttachment)
apiAttachment := DecodeJSON(t, resp, &api.Attachment{})
expect := convert.ToAPIAttachment(repo, attachment)
assert.Equal(t, expect.ID, apiAttachment.ID)
@@ -75,8 +74,7 @@ func TestAPIListCommentAttachments(t *testing.T) {
AddTokenAuth(token)
resp := session.MakeRequest(t, req, http.StatusOK)
var apiAttachments []*api.Attachment
DecodeJSON(t, resp, &apiAttachments)
apiAttachments := DecodeJSON(t, resp, []*api.Attachment{})
expectedCount := unittest.GetCount(t, &repo_model.Attachment{CommentID: comment.ID})
assert.Len(t, apiAttachments, expectedCount)
@@ -110,9 +108,7 @@ func TestAPICreateCommentAttachment(t *testing.T) {
SetHeader("Content-Type", writer.FormDataContentType())
resp := session.MakeRequest(t, req, http.StatusCreated)
apiAttachment := new(api.Attachment)
DecodeJSON(t, resp, &apiAttachment)
apiAttachment := DecodeJSON(t, resp, &api.Attachment{})
unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, CommentID: comment.ID})
}
@@ -163,9 +159,7 @@ func TestAPIEditCommentAttachment(t *testing.T) {
"name": newAttachmentName,
}).AddTokenAuth(token)
resp := session.MakeRequest(t, req, http.StatusCreated)
apiAttachment := new(api.Attachment)
DecodeJSON(t, resp, &apiAttachment)
apiAttachment := DecodeJSON(t, resp, &api.Attachment{})
unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, CommentID: comment.ID, Name: apiAttachment.Name})
}
@@ -9,14 +9,14 @@ import (
"net/url"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/services/convert"
"code.gitea.io/gitea/tests"
auth_model "gitea.dev/models/auth"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"gitea.dev/services/convert"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -34,8 +34,7 @@ func TestAPIListRepoComments(t *testing.T) {
req := NewRequest(t, "GET", link.String())
resp := MakeRequest(t, req, http.StatusOK)
var apiComments []*api.Comment
DecodeJSON(t, resp, &apiComments)
apiComments := DecodeJSON(t, resp, []*api.Comment{})
assert.Len(t, apiComments, 2)
for _, apiComment := range apiComments {
c := &issues_model.Comment{ID: apiComment.ID}
@@ -52,7 +51,7 @@ func TestAPIListRepoComments(t *testing.T) {
link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String())
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiComments)
apiComments = DecodeJSON(t, resp, []*api.Comment{})
assert.Len(t, apiComments, 1)
assert.EqualValues(t, 2, apiComments[0].ID)
@@ -61,7 +60,7 @@ func TestAPIListRepoComments(t *testing.T) {
link.RawQuery = query.Encode()
req = NewRequest(t, "GET", link.String())
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiComments)
apiComments = DecodeJSON(t, resp, []*api.Comment{})
assert.Len(t, apiComments, 1)
assert.EqualValues(t, 3, apiComments[0].ID)
}
@@ -80,8 +79,7 @@ func TestAPIListIssueComments(t *testing.T) {
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var comments []*api.Comment
DecodeJSON(t, resp, &comments)
comments := DecodeJSON(t, resp, []*api.Comment{})
expectedCount := unittest.GetCount(t, &issues_model.Comment{IssueID: issue.ID},
unittest.Cond("type = ?", issues_model.CommentTypeComment))
assert.Len(t, comments, expectedCount)
@@ -103,8 +101,7 @@ func TestAPICreateComment(t *testing.T) {
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var updatedComment api.Comment
DecodeJSON(t, resp, &updatedComment)
updatedComment := DecodeJSON(t, resp, &api.Comment{})
assert.Equal(t, commentBody, updatedComment.Body)
unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody})
@@ -150,8 +147,7 @@ func TestAPIGetComment(t *testing.T) {
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var apiComment api.Comment
DecodeJSON(t, resp, &apiComment)
apiComment := DecodeJSON(t, resp, &api.Comment{})
assert.NoError(t, comment.LoadPoster(t.Context()))
expect := convert.ToAPIComment(t.Context(), repo, comment)
@@ -186,8 +182,7 @@ func TestAPIGetSystemUserComment(t *testing.T) {
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d", repoOwner.Name, repo.Name, comment.ID)
resp := MakeRequest(t, req, http.StatusOK)
var apiComment api.Comment
DecodeJSON(t, resp, &apiComment)
apiComment := DecodeJSON(t, resp, &api.Comment{})
if assert.NotNil(t, apiComment.Poster) {
if assert.Equal(t, systemUser.ID, apiComment.Poster.ID) {
@@ -230,8 +225,7 @@ func TestAPIEditComment(t *testing.T) {
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var updatedComment api.Comment
DecodeJSON(t, resp, &updatedComment)
updatedComment := DecodeJSON(t, resp, &api.Comment{})
assert.Equal(t, comment.ID, updatedComment.ID)
assert.Equal(t, newCommentBody, updatedComment.Body)
unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody})
@@ -278,8 +272,7 @@ func TestAPIListIssueTimeline(t *testing.T) {
// check if lens of list returned by API and
// lists extracted directly from DB are the same
var comments []*api.TimelineComment
DecodeJSON(t, resp, &comments)
comments := DecodeJSON(t, resp, []*api.TimelineComment{})
expectedCount := unittest.GetCount(t, &issues_model.Comment{IssueID: issue.ID})
assert.Len(t, comments, expectedCount)
}
@@ -7,14 +7,14 @@ import (
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
org_model "code.gitea.io/gitea/models/organization"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
org_service "code.gitea.io/gitea/services/org"
"code.gitea.io/gitea/tests"
auth_model "gitea.dev/models/auth"
org_model "gitea.dev/models/organization"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
org_service "gitea.dev/services/org"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -89,7 +89,7 @@ func testAPIForkListLimitedAndPrivateRepos(t *testing.T) {
assert.Equal(t, "0", resp.Header().Get("X-Total-Count"))
})
t.Run("Logged in", func(t *testing.T) {
t.Run("LoggedIn", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks").AddTokenAuth(user1Token)
@@ -107,6 +107,36 @@ func testAPIForkListLimitedAndPrivateRepos(t *testing.T) {
assert.Len(t, forks, 2)
assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
})
t.Run("RespHeaderLinks", func(t *testing.T) {
t.Run("Page1", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks?page=1&limit=1").AddTokenAuth(user1Token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
linkHeader := resp.Header().Get("Link")
assert.NotEmpty(t, linkHeader, "Link header should not be empty")
assert.Contains(t, linkHeader, `rel="next"`)
assert.Contains(t, linkHeader, `rel="last"`)
assert.Contains(t, linkHeader, `/api/v1/repos/user2/repo1/forks?limit=1&page=2>`)
forks := DecodeJSON(t, resp, []*api.Repository{})
assert.Len(t, forks, 1)
})
t.Run("Page2", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks?page=2&limit=1").AddTokenAuth(user1Token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
forks := DecodeJSON(t, resp, []*api.Repository{})
assert.Len(t, forks, 1)
})
})
}
func testGetPrivateReposForks(t *testing.T) {
@@ -7,10 +7,10 @@ import (
"net/http"
"testing"
"code.gitea.io/gitea/modules/options"
repo_module "code.gitea.io/gitea/modules/repository"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
"gitea.dev/modules/options"
repo_module "gitea.dev/modules/repository"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -21,9 +21,9 @@ func TestAPIListGitignoresTemplates(t *testing.T) {
req := NewRequest(t, "GET", "/api/v1/gitignore/templates")
resp := MakeRequest(t, req, http.StatusOK)
// This tests if the API returns a list of strings
var gitignoreList []string
DecodeJSON(t, resp, &gitignoreList)
templateList := DecodeJSON(t, resp, []string{}) // this is a very long list
assert.Contains(t, templateList, "C++")
assert.Contains(t, templateList, "Go")
}
func TestAPIGetGitignoreTemplateInfo(t *testing.T) {
@@ -41,8 +41,7 @@ func TestAPIGetGitignoreTemplateInfo(t *testing.T) {
req := NewRequest(t, "GET", urlStr)
resp := MakeRequest(t, req, http.StatusOK)
var templateInfo api.GitignoreTemplateInfo
DecodeJSON(t, resp, &templateInfo)
templateInfo := DecodeJSON(t, resp, &api.GitignoreTemplateInfo{})
// We get the text of the template here
text, _ := options.Gitignore(templateName)
@@ -9,9 +9,9 @@ import (
"strconv"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
auth_model "gitea.dev/models/auth"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -77,12 +77,10 @@ func TestGPGKeys(t *testing.T) {
// Check state after basic add
t.Run("CheckState", func(t *testing.T) {
var keys []*api.GPGKey
req := NewRequest(t, "GET", "/api/v1/user/gpg_keys"). // GET all keys
AddTokenAuth(tokenWithGPGKeyScope)
resp := MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &keys)
keys := DecodeJSON(t, resp, []*api.GPGKey{})
assert.Len(t, keys, 1)
primaryKey1 := keys[0] // Primary key 1
@@ -95,11 +93,10 @@ func TestGPGKeys(t *testing.T) {
assert.Equal(t, "70D7C694D17D03AD", subKey.KeyID)
assert.Empty(t, subKey.Emails)
var key api.GPGKey
req = NewRequest(t, "GET", "/api/v1/user/gpg_keys/"+strconv.FormatInt(primaryKey1.ID, 10)). // Primary key 1
AddTokenAuth(tokenWithGPGKeyScope)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &key)
key := DecodeJSON(t, resp, &api.GPGKey{})
assert.Equal(t, "38EA3BCED732982C", key.KeyID)
assert.Len(t, key.Emails, 1)
assert.Equal(t, "user2@example.com", key.Emails[0].Email)
@@ -108,7 +105,7 @@ func TestGPGKeys(t *testing.T) {
req = NewRequest(t, "GET", "/api/v1/user/gpg_keys/"+strconv.FormatInt(subKey.ID, 10)). // Subkey of 38EA3BCED732982C
AddTokenAuth(tokenWithGPGKeyScope)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &key)
key = DecodeJSON(t, resp, &api.GPGKey{})
assert.Equal(t, "70D7C694D17D03AD", key.KeyID)
assert.Empty(t, key.Emails)
})
@@ -116,29 +113,26 @@ func TestGPGKeys(t *testing.T) {
// Check state after basic add
t.Run("CheckCommits", func(t *testing.T) {
t.Run("NotSigned", func(t *testing.T) {
var branch api.Branch
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo16/branches/not-signed").
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &branch)
branch := DecodeJSON(t, resp, &api.Branch{})
assert.False(t, branch.Commit.Verification.Verified)
})
t.Run("SignedWithNotValidatedEmail", func(t *testing.T) {
var branch api.Branch
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo16/branches/good-sign-not-yet-validated").
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &branch)
branch := DecodeJSON(t, resp, &api.Branch{})
assert.False(t, branch.Commit.Verification.Verified)
})
t.Run("SignedWithValidEmail", func(t *testing.T) {
var branch api.Branch
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo16/branches/good-sign").
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &branch)
branch := DecodeJSON(t, resp, &api.Branch{})
assert.True(t, branch.Commit.Verification.Verified)
})
})
@@ -12,13 +12,13 @@ import (
"testing"
"time"
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/queue"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/services/forms"
"gitea.dev/models/auth"
"gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/json"
"gitea.dev/modules/queue"
api "gitea.dev/modules/structs"
"gitea.dev/services/forms"
"github.com/stretchr/testify/assert"
)
@@ -70,10 +70,9 @@ func doAPICreateRepository(ctx APITestContext, empty bool, callback ...func(*tes
}
resp := ctx.Session.MakeRequest(t, req, http.StatusCreated)
var repository api.Repository
DecodeJSON(t, resp, &repository)
repository := DecodeJSON(t, resp, &api.Repository{})
if len(callback) > 0 {
callback[0](t, repository)
callback[0](t, *repository)
}
}
}
@@ -88,22 +87,21 @@ func doAPIEditRepository(ctx APITestContext, editRepoOption *api.EditRepoOption,
}
resp := ctx.Session.MakeRequest(t, req, http.StatusOK)
var repository api.Repository
DecodeJSON(t, resp, &repository)
repository := DecodeJSON(t, resp, &api.Repository{})
if len(callback) > 0 {
callback[0](t, repository)
callback[0](t, *repository)
}
}
}
func doAPIAddCollaborator(ctx APITestContext, username string, mode perm.AccessMode) func(*testing.T) {
return func(t *testing.T) {
permission := "read"
permission := api.RepoWritePermissionRead
if mode == perm.AccessModeAdmin {
permission = "admin"
permission = api.RepoWritePermissionAdmin
} else if mode > perm.AccessModeRead {
permission = "write"
permission = api.RepoWritePermissionWrite
}
addCollaboratorOption := &api.AddCollaboratorOption{
Permission: &permission,
@@ -128,10 +126,9 @@ func doAPIForkRepository(ctx APITestContext, username string, callback ...func(*
return
}
resp := ctx.Session.MakeRequest(t, req, http.StatusAccepted)
var repository api.Repository
DecodeJSON(t, resp, &repository)
repository := DecodeJSON(t, resp, &api.Repository{})
if len(callback) > 0 {
callback[0](t, repository)
callback[0](t, *repository)
}
}
}
@@ -146,10 +143,9 @@ func doAPIGetRepository(ctx APITestContext, callback ...func(*testing.T, api.Rep
}
resp := ctx.Session.MakeRequest(t, req, http.StatusOK)
var repository api.Repository
DecodeJSON(t, resp, &repository)
repository := DecodeJSON(t, resp, &api.Repository{})
if len(callback) > 0 {
callback[0](t, repository)
callback[0](t, *repository)
}
}
}
@@ -179,10 +175,9 @@ func doAPICreateUserKey(ctx APITestContext, keyname, keyFile string, callback ..
return
}
resp := ctx.Session.MakeRequest(t, req, http.StatusCreated)
var publicKey api.PublicKey
DecodeJSON(t, resp, &publicKey)
publicKey := DecodeJSON(t, resp, &api.PublicKey{})
if len(callback) > 0 {
callback[0](t, publicKey)
callback[0](t, *publicKey)
}
}
}
@@ -274,8 +269,7 @@ func doAPIMergePullRequest(ctx APITestContext, owner, repo string, index int64)
if resp.Code != http.StatusMethodNotAllowed {
break
}
err := api.APIError{}
DecodeJSON(t, resp, &err)
err := DecodeJSON(t, resp, &api.APIError{})
assert.Equal(t, "Please try again later", err.Message)
queue.GetManager().FlushAll(t.Context(), 5*time.Second)
<-time.After(1 * time.Second)
@@ -348,10 +342,9 @@ func doAPIGetBranch(ctx APITestContext, branch string, callback ...func(*testing
}
resp := ctx.Session.MakeRequest(t, req, http.StatusOK)
var branch api.Branch
DecodeJSON(t, resp, &branch)
branch := DecodeJSON(t, resp, &api.Branch{})
if len(callback) > 0 {
callback[0](t, branch)
callback[0](t, *branch)
}
}
}
@@ -366,10 +359,9 @@ func doAPICreateFile(ctx APITestContext, treepath string, options *api.CreateFil
}
resp := ctx.Session.MakeRequest(t, req, http.StatusCreated)
var contents api.FileResponse
DecodeJSON(t, resp, &contents)
contents := DecodeJSON(t, resp, &api.FileResponse{})
if len(callback) > 0 {
callback[0](t, contents)
callback[0](t, *contents)
}
}
}
@@ -384,10 +376,9 @@ func doAPICreateOrganization(ctx APITestContext, options *api.CreateOrgOption, c
}
resp := ctx.Session.MakeRequest(t, req, http.StatusCreated)
var contents api.Organization
DecodeJSON(t, resp, &contents)
contents := DecodeJSON(t, resp, &api.Organization{})
if len(callback) > 0 {
callback[0](t, contents)
callback[0](t, *contents)
}
}
}
@@ -402,10 +393,9 @@ func doAPICreateOrganizationRepository(ctx APITestContext, orgName string, optio
}
resp := ctx.Session.MakeRequest(t, req, http.StatusCreated)
var contents api.Repository
DecodeJSON(t, resp, &contents)
contents := DecodeJSON(t, resp, &api.Repository{})
if len(callback) > 0 {
callback[0](t, contents)
callback[0](t, *contents)
}
}
}
@@ -420,10 +410,9 @@ func doAPICreateOrganizationTeam(ctx APITestContext, orgName string, options *ap
}
resp := ctx.Session.MakeRequest(t, req, http.StatusCreated)
var contents api.Team
DecodeJSON(t, resp, &contents)
contents := DecodeJSON(t, resp, &api.Team{})
if len(callback) > 0 {
callback[0](t, contents)
callback[0](t, *contents)
}
}
}
@@ -9,11 +9,11 @@ import (
"net/url"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/tests"
auth_model "gitea.dev/models/auth"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/test"
"gitea.dev/tests"
"github.com/42wim/httpsig"
"golang.org/x/crypto/ssh"
@@ -10,13 +10,13 @@ import (
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
auth_model "gitea.dev/models/auth"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -35,8 +35,7 @@ func TestAPIGetIssueAttachment(t *testing.T) {
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets/%d", repoOwner.Name, repo.Name, issue.Index, attachment.ID)).
AddTokenAuth(token)
resp := session.MakeRequest(t, req, http.StatusOK)
apiAttachment := new(api.Attachment)
DecodeJSON(t, resp, &apiAttachment)
apiAttachment := DecodeJSON(t, resp, &api.Attachment{})
unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID})
}
@@ -55,10 +54,9 @@ func TestAPIListIssueAttachments(t *testing.T) {
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", repoOwner.Name, repo.Name, issue.Index)).
AddTokenAuth(token)
resp := session.MakeRequest(t, req, http.StatusOK)
apiAttachment := new([]api.Attachment)
DecodeJSON(t, resp, &apiAttachment)
apiAttachment := DecodeJSON(t, resp, []api.Attachment{})
unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: (*apiAttachment)[0].ID, IssueID: issue.ID})
unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment[0].ID, IssueID: issue.ID})
}
func TestAPICreateIssueAttachment(t *testing.T) {
@@ -87,8 +85,7 @@ func TestAPICreateIssueAttachment(t *testing.T) {
req.Header.Add("Content-Type", writer.FormDataContentType())
resp := session.MakeRequest(t, req, http.StatusCreated)
apiAttachment := new(api.Attachment)
DecodeJSON(t, resp, &apiAttachment)
apiAttachment := DecodeJSON(t, resp, &api.Attachment{})
unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID})
}
@@ -138,8 +135,7 @@ func TestAPIEditIssueAttachment(t *testing.T) {
"name": newAttachmentName,
}).AddTokenAuth(token)
resp := session.MakeRequest(t, req, http.StatusCreated)
apiAttachment := new(api.Attachment)
DecodeJSON(t, resp, &apiAttachment)
apiAttachment := DecodeJSON(t, resp, &api.Attachment{})
unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID, Name: apiAttachment.Name})
}
@@ -8,15 +8,15 @@ import (
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
auth_model "gitea.dev/models/auth"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
"go.yaml.in/yaml/v4"
)
func createIssueConfig(t *testing.T, user *user_model.User, repo *repo_model.Repository, issueConfig map[string]any) {
@@ -32,10 +32,9 @@ func getIssueConfig(t *testing.T, owner, repo string) api.IssueConfig {
req := NewRequest(t, "GET", urlStr)
resp := MakeRequest(t, req, http.StatusOK)
var issueConfig api.IssueConfig
DecodeJSON(t, resp, &issueConfig)
issueConfig := DecodeJSON(t, resp, &api.IssueConfig{})
return issueConfig
return *issueConfig
}
func TestAPIRepoGetIssueConfig(t *testing.T) {
@@ -157,8 +156,7 @@ func TestAPIRepoValidateIssueConfig(t *testing.T) {
req := NewRequest(t, "GET", urlStr)
resp := MakeRequest(t, req, http.StatusOK)
var issueConfigValidation api.IssueConfigValidation
DecodeJSON(t, resp, &issueConfigValidation)
issueConfigValidation := DecodeJSON(t, resp, &api.IssueConfigValidation{})
assert.True(t, issueConfigValidation.Valid)
assert.Empty(t, issueConfigValidation.Message)
@@ -173,8 +171,7 @@ func TestAPIRepoValidateIssueConfig(t *testing.T) {
req := NewRequest(t, "GET", urlStr)
resp := MakeRequest(t, req, http.StatusOK)
var issueConfigValidation api.IssueConfigValidation
DecodeJSON(t, resp, &issueConfigValidation)
issueConfigValidation := DecodeJSON(t, resp, &api.IssueConfigValidation{})
assert.False(t, issueConfigValidation.Valid)
assert.NotEmpty(t, issueConfigValidation.Message)
@@ -6,20 +6,21 @@ package integration
import (
"fmt"
"net/http"
"strconv"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
repo_service "code.gitea.io/gitea/services/repository"
"code.gitea.io/gitea/tests"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
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"
api "gitea.dev/modules/structs"
repo_service "gitea.dev/services/repository"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -150,3 +151,47 @@ func TestAPIDeleteIssueDependencyCrossRepoPermission(t *testing.T) {
DependencyID: dependencyIssue.ID,
})
}
func TestWebDeleteIssueDependencyCrossRepoPermission(t *testing.T) {
defer tests.PrepareTestEnv(t)()
targetRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
targetIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: targetRepo.ID, Index: 1})
dependencyRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
assert.True(t, dependencyRepo.IsPrivate)
dependencyIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: dependencyRepo.ID, Index: 1})
enableRepoDependencies(t, targetIssue.RepoID)
enableRepoDependencies(t, dependencyRepo.ID)
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
assert.NoError(t, issues_model.CreateIssueDependency(t.Context(), user1, targetIssue, dependencyIssue))
user40 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 40})
assert.NoError(t, repo_service.AddOrUpdateCollaborator(t.Context(), targetRepo, user40, perm.AccessModeWrite))
session := loginUser(t, user40.Name)
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/user2/repo1/issues/%d/dependency/delete", targetIssue.Index), map[string]string{
"removeDependencyID": strconv.FormatInt(dependencyIssue.ID, 10),
"dependencyType": "blockedBy",
})
session.MakeRequest(t, req, http.StatusSeeOther)
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueDependency{
IssueID: targetIssue.ID,
DependencyID: dependencyIssue.ID,
})
assert.NoError(t, repo_service.AddOrUpdateCollaborator(t.Context(), dependencyRepo, user40, perm.AccessModeRead))
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/user2/repo1/issues/%d/dependency/delete", targetIssue.Index), map[string]string{
"removeDependencyID": strconv.FormatInt(dependencyIssue.ID, 10),
"dependencyType": "blockedBy",
})
session.MakeRequest(t, req, http.StatusSeeOther)
unittest.AssertNotExistsBean(t, &issues_model.IssueDependency{
IssueID: targetIssue.ID,
DependencyID: dependencyIssue.ID,
})
}
@@ -9,12 +9,12 @@ import (
"strings"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
auth_model "gitea.dev/models/auth"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"github.com/stretchr/testify/assert"
)
@@ -35,8 +35,7 @@ func TestAPIModifyLabels(t *testing.T) {
Description: "test label",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
apiLabel := new(api.Label)
DecodeJSON(t, resp, &apiLabel)
apiLabel := DecodeJSON(t, resp, &api.Label{})
dbLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: apiLabel.ID, RepoID: repo.ID})
assert.Equal(t, dbLabel.Name, apiLabel.Name)
assert.Equal(t, strings.TrimLeft(dbLabel.Color, "#"), apiLabel.Color)
@@ -57,8 +56,7 @@ func TestAPIModifyLabels(t *testing.T) {
req = NewRequest(t, "GET", urlStr).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var apiLabels []*api.Label
DecodeJSON(t, resp, &apiLabels)
apiLabels := DecodeJSON(t, resp, []*api.Label{})
assert.Len(t, apiLabels, 2)
// GetLabel
@@ -66,7 +64,7 @@ func TestAPIModifyLabels(t *testing.T) {
req = NewRequest(t, "GET", singleURLStr).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiLabel)
apiLabel = DecodeJSON(t, resp, &api.Label{})
assert.Equal(t, strings.TrimLeft(dbLabel.Color, "#"), apiLabel.Color)
// EditLabel
@@ -78,7 +76,7 @@ func TestAPIModifyLabels(t *testing.T) {
Color: &newColor,
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiLabel)
apiLabel = DecodeJSON(t, resp, &api.Label{})
assert.Equal(t, newColor, apiLabel.Color)
req = NewRequestWithJSON(t, "PATCH", singleURLStr, &api.EditLabelOption{
Color: &newColorWrong,
@@ -107,13 +105,45 @@ func TestAPIAddIssueLabels(t *testing.T) {
Labels: []any{1, 2},
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var apiLabels []*api.Label
DecodeJSON(t, resp, &apiLabels)
apiLabels := DecodeJSON(t, resp, []*api.Label{})
assert.Len(t, apiLabels, unittest.GetCount(t, &issues_model.IssueLabel{IssueID: issue.ID}))
unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: 2})
}
// TestAPIDeleteIssueLabelCrossRepo ensures DeleteIssueLabel does not act on a label
// belonging to another repository, and that a foreign-but-existing label ID and a
// nonexistent label ID return the same status (no cross-repo enumeration oracle).
func TestAPIDeleteIssueLabelCrossRepo(t *testing.T) {
assert.NoError(t, unittest.LoadFixtures())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
// label 5 exists but belongs to repo 10, not repo 1
foreignLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 5})
assert.NotEqual(t, repo.ID, foreignLabel.RepoID)
session := loginUser(t, owner.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
base := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", repo.OwnerName, repo.Name, issue.Index)
// a foreign-but-existing label ID must not be accepted (was 204 before the fix)
req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%d", base, foreignLabel.ID)).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// a nonexistent label ID must return the SAME status, so no oracle exists (was 422 before the fix)
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%d", base, 9999999)).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// a label that belongs to the repo is still removable
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 2, RepoID: repo.ID})
addReq := NewRequestWithJSON(t, "POST", base, &api.IssueLabelsOption{Labels: []any{2}}).AddTokenAuth(token)
MakeRequest(t, addReq, http.StatusOK)
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%d", base, 2)).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNoContent)
}
func TestAPIAddIssueLabelsWithLabelNames(t *testing.T) {
assert.NoError(t, unittest.LoadFixtures())
@@ -132,8 +162,7 @@ func TestAPIAddIssueLabelsWithLabelNames(t *testing.T) {
Labels: []any{repoLabel.Name, orgLabel.Name},
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var apiLabels []*api.Label
DecodeJSON(t, resp, &apiLabels)
apiLabels := DecodeJSON(t, resp, []*api.Label{})
assert.Len(t, apiLabels, unittest.GetCount(t, &issues_model.IssueLabel{IssueID: issue.ID}))
var apiLabelNames []string
for _, label := range apiLabels {
@@ -162,8 +191,7 @@ func TestAPIReplaceIssueLabels(t *testing.T) {
Labels: []any{label.ID},
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var apiLabels []*api.Label
DecodeJSON(t, resp, &apiLabels)
apiLabels := DecodeJSON(t, resp, []*api.Label{})
if assert.Len(t, apiLabels, 1) {
assert.Equal(t, label.ID, apiLabels[0].ID)
}
@@ -188,8 +216,7 @@ func TestAPIReplaceIssueLabelsWithLabelNames(t *testing.T) {
Labels: []any{label.Name},
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var apiLabels []*api.Label
DecodeJSON(t, resp, &apiLabels)
apiLabels := DecodeJSON(t, resp, []*api.Label{})
if assert.Len(t, apiLabels, 1) {
assert.Equal(t, label.Name, apiLabels[0].Name)
}
@@ -212,8 +239,7 @@ func TestAPIModifyOrgLabels(t *testing.T) {
Description: "test label",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
apiLabel := new(api.Label)
DecodeJSON(t, resp, &apiLabel)
apiLabel := DecodeJSON(t, resp, &api.Label{})
dbLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: apiLabel.ID, OrgID: owner.ID})
assert.Equal(t, dbLabel.Name, apiLabel.Name)
assert.Equal(t, strings.TrimLeft(dbLabel.Color, "#"), apiLabel.Color)
@@ -234,8 +260,7 @@ func TestAPIModifyOrgLabels(t *testing.T) {
req = NewRequest(t, "GET", urlStr).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var apiLabels []*api.Label
DecodeJSON(t, resp, &apiLabels)
apiLabels := DecodeJSON(t, resp, []*api.Label{})
assert.Len(t, apiLabels, 4)
// GetLabel
@@ -243,7 +268,7 @@ func TestAPIModifyOrgLabels(t *testing.T) {
req = NewRequest(t, "GET", singleURLStr).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiLabel)
apiLabel = DecodeJSON(t, resp, &api.Label{})
assert.Equal(t, strings.TrimLeft(dbLabel.Color, "#"), apiLabel.Color)
// EditLabel
@@ -255,7 +280,7 @@ func TestAPIModifyOrgLabels(t *testing.T) {
Color: &newColor,
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiLabel)
apiLabel = DecodeJSON(t, resp, &api.Label{})
assert.Equal(t, newColor, apiLabel.Color)
req = NewRequestWithJSON(t, "PATCH", singleURLStr, &api.EditLabelOption{
Color: &newColorWrong,
@@ -8,13 +8,13 @@ import (
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
auth_model "gitea.dev/models/auth"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -8,13 +8,13 @@ import (
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
auth_model "gitea.dev/models/auth"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -39,15 +39,13 @@ func TestAPIIssuesMilestone(t *testing.T) {
State: &milestoneState,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var apiMilestone structs.Milestone
DecodeJSON(t, resp, &apiMilestone)
apiMilestone := DecodeJSON(t, resp, &structs.Milestone{})
assert.EqualValues(t, "closed", apiMilestone.State)
req = NewRequest(t, "GET", urlStr).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var apiMilestone2 structs.Milestone
DecodeJSON(t, resp, &apiMilestone2)
apiMilestone2 := DecodeJSON(t, resp, &structs.Milestone{})
assert.EqualValues(t, "closed", apiMilestone2.State)
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/milestones", owner.Name, repo.Name), structs.CreateMilestoneOption{
@@ -56,29 +54,28 @@ func TestAPIIssuesMilestone(t *testing.T) {
State: "closed",
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusCreated)
DecodeJSON(t, resp, &apiMilestone)
apiMilestone = DecodeJSON(t, resp, &structs.Milestone{})
assert.Equal(t, "wow", apiMilestone.Title)
assert.Equal(t, structs.StateClosed, apiMilestone.State)
assert.Nil(t, apiMilestone.Deadline)
var apiMilestones []structs.Milestone
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/milestones?state=%s", owner.Name, repo.Name, "all")).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiMilestones)
apiMilestones := DecodeJSON(t, resp, []structs.Milestone{})
assert.Len(t, apiMilestones, 4)
assert.Nil(t, apiMilestones[0].Deadline)
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/milestones/%s", owner.Name, repo.Name, apiMilestones[2].Title)).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiMilestone)
assert.Equal(t, apiMilestones[2], apiMilestone)
apiMilestone = DecodeJSON(t, resp, &structs.Milestone{})
assert.Equal(t, apiMilestones[2], *apiMilestone)
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/milestones?state=%s&name=%s", owner.Name, repo.Name, "all", "milestone2")).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &apiMilestones)
apiMilestones = DecodeJSON(t, resp, []structs.Milestone{})
assert.Len(t, apiMilestones, 1)
assert.Equal(t, int64(2), apiMilestones[0].ID)
@@ -8,13 +8,13 @@ import (
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
auth_model "gitea.dev/models/auth"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -39,8 +39,7 @@ func TestAPIPinIssue(t *testing.T) {
// Check if the Issue is pinned
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index))
resp := MakeRequest(t, req, http.StatusOK)
var issueAPI api.Issue
DecodeJSON(t, resp, &issueAPI)
issueAPI := DecodeJSON(t, resp, &api.Issue{})
assert.Equal(t, 1, issueAPI.PinOrder)
}
@@ -64,8 +63,7 @@ func TestAPIUnpinIssue(t *testing.T) {
// Check if the Issue is pinned
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index))
resp := MakeRequest(t, req, http.StatusOK)
var issueAPI api.Issue
DecodeJSON(t, resp, &issueAPI)
issueAPI := DecodeJSON(t, resp, &api.Issue{})
assert.Equal(t, 1, issueAPI.PinOrder)
// Unpin the Issue
@@ -76,7 +74,7 @@ func TestAPIUnpinIssue(t *testing.T) {
// Check if the Issue is no longer pinned
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index))
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &issueAPI)
issueAPI = DecodeJSON(t, resp, &api.Issue{})
assert.Equal(t, 0, issueAPI.PinOrder)
}
@@ -101,8 +99,7 @@ func TestAPIMoveIssuePin(t *testing.T) {
// Check if the first Issue is pinned at position 1
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index))
resp := MakeRequest(t, req, http.StatusOK)
var issueAPI api.Issue
DecodeJSON(t, resp, &issueAPI)
issueAPI := DecodeJSON(t, resp, &api.Issue{})
assert.Equal(t, 1, issueAPI.PinOrder)
// Pin the second Issue
@@ -118,15 +115,13 @@ func TestAPIMoveIssuePin(t *testing.T) {
// Check if the first Issue is pinned at position 2
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index))
resp = MakeRequest(t, req, http.StatusOK)
var issueAPI3 api.Issue
DecodeJSON(t, resp, &issueAPI3)
issueAPI3 := DecodeJSON(t, resp, &api.Issue{})
assert.Equal(t, 2, issueAPI3.PinOrder)
// Check if the second Issue is pinned at position 1
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue2.Index))
resp = MakeRequest(t, req, http.StatusOK)
var issueAPI4 api.Issue
DecodeJSON(t, resp, &issueAPI4)
issueAPI4 := DecodeJSON(t, resp, &api.Issue{})
assert.Equal(t, 1, issueAPI4.PinOrder)
}
@@ -150,8 +145,7 @@ func TestAPIListPinnedIssues(t *testing.T) {
// Check if the Issue is in the List
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/pinned", repo.OwnerName, repo.Name))
resp := MakeRequest(t, req, http.StatusOK)
var issueList []api.Issue
DecodeJSON(t, resp, &issueList)
issueList := DecodeJSON(t, resp, []api.Issue{})
assert.Len(t, issueList, 1)
assert.Equal(t, issue.ID, issueList[0].ID)
@@ -166,8 +160,7 @@ func TestAPIListPinnedPullrequests(t *testing.T) {
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/pinned", repo.OwnerName, repo.Name))
resp := MakeRequest(t, req, http.StatusOK)
var prList []api.PullRequest
DecodeJSON(t, resp, &prList)
prList := DecodeJSON(t, resp, []api.PullRequest{})
assert.Empty(t, prList)
}
@@ -181,8 +174,7 @@ func TestAPINewPinAllowed(t *testing.T) {
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/new_pin_allowed", owner.Name, repo.Name))
resp := MakeRequest(t, req, http.StatusOK)
var newPinsAllowed api.NewIssuePinsAllowed
DecodeJSON(t, resp, &newPinsAllowed)
newPinsAllowed := DecodeJSON(t, resp, &api.NewIssuePinsAllowed{})
assert.True(t, newPinsAllowed.Issues)
assert.True(t, newPinsAllowed.PullRequests)
@@ -9,14 +9,14 @@ import (
"testing"
"time"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/services/convert"
"code.gitea.io/gitea/tests"
auth_model "gitea.dev/models/auth"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"gitea.dev/services/convert"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -51,8 +51,7 @@ func TestAPIIssuesReactions(t *testing.T) {
Reaction: "rocket",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var apiNewReaction api.Reaction
DecodeJSON(t, resp, &apiNewReaction)
apiNewReaction := DecodeJSON(t, resp, &api.Reaction{})
// Add existing reaction
req = NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{
@@ -71,15 +70,14 @@ func TestAPIIssuesReactions(t *testing.T) {
req = NewRequest(t, "GET", urlStr).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var apiReactions []*api.Reaction
DecodeJSON(t, resp, &apiReactions)
apiReactions := DecodeJSON(t, resp, []*api.Reaction{})
expectResponse := make(map[int]api.Reaction)
expectResponse[0] = api.Reaction{
User: convert.ToUser(t.Context(), user2, user2),
Reaction: "eyes",
Created: time.Unix(1573248003, 0),
}
expectResponse[1] = apiNewReaction
expectResponse[1] = *apiNewReaction
assert.Len(t, apiReactions, 2)
for i, r := range apiReactions {
assert.Equal(t, expectResponse[i].Reaction, r.Reaction)
@@ -141,8 +139,7 @@ func TestAPICommentReactions(t *testing.T) {
Reaction: "+1",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var apiNewReaction api.Reaction
DecodeJSON(t, resp, &apiNewReaction)
apiNewReaction := DecodeJSON(t, resp, &api.Reaction{})
// Add existing reaction
req = NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{
@@ -154,8 +151,7 @@ func TestAPICommentReactions(t *testing.T) {
req = NewRequest(t, "GET", urlStr).
AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
var apiReactions []*api.Reaction
DecodeJSON(t, resp, &apiReactions)
apiReactions := DecodeJSON(t, resp, []*api.Reaction{})
expectResponse := make(map[int]api.Reaction)
expectResponse[0] = api.Reaction{
User: convert.ToUser(t.Context(), user2, user2),
@@ -167,7 +163,7 @@ func TestAPICommentReactions(t *testing.T) {
Reaction: "laugh",
Created: time.Unix(1573248005, 0),
}
expectResponse[2] = apiNewReaction
expectResponse[2] = *apiNewReaction
assert.Len(t, apiReactions, 3)
for i, r := range apiReactions {
assert.Equal(t, expectResponse[i].Reaction, r.Reaction)
@@ -7,13 +7,13 @@ import (
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
auth_model "gitea.dev/models/auth"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -29,8 +29,7 @@ func TestAPIListStopWatches(t *testing.T) {
req := NewRequest(t, "GET", "/api/v1/user/stopwatches").
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
var apiWatches []*api.StopWatch
DecodeJSON(t, resp, &apiWatches)
apiWatches := DecodeJSON(t, resp, []*api.StopWatch{})
stopwatch := unittest.AssertExistsAndLoadBean(t, &issues_model.Stopwatch{UserID: owner.ID})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: stopwatch.IssueID})
if assert.Len(t, apiWatches, 1) {
@@ -8,13 +8,13 @@ import (
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
auth_model "gitea.dev/models/auth"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
@@ -39,8 +39,7 @@ func TestAPIIssueSubscriptions(t *testing.T) {
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/subscriptions/check", issueRepo.OwnerName, issueRepo.Name, issue.Index)).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
wi := new(api.WatchInfo)
DecodeJSON(t, resp, wi)
wi := DecodeJSON(t, resp, &api.WatchInfo{})
assert.Equal(t, isWatching, wi.Subscribed)
assert.Equal(t, !isWatching, wi.Ignored)

Some files were not shown because too many files have changed in this diff Show More