This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
+3
@@ -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"}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -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"}]
|
||||
+3
@@ -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"}]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -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}]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[{"id":1,"name":"Bug","color":"#ee0701","description":""},{"id":2,"name":"Question","color":"#d876e3","description":""}]
|
||||
+3
@@ -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"}]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
[]
|
||||
+3
File diff suppressed because one or more lines are too long
+3
@@ -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":[]}]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
{"topics":[]}
|
||||
+3
@@ -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}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
Content-Type: application/json
|
||||
|
||||
{"version":"1.22.0"}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
Content-Type: text/plain
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
Content-Type: text/plain
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
Content-Type: text/plain
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
Content-Type: text/plain
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
Content-Type: text/plain
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
Content-Type: text/plain
|
||||
|
||||
@@ -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, ®istrationToken)
|
||||
}{})
|
||||
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, ®istrationToken)
|
||||
}{})
|
||||
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, ®istrationToken)
|
||||
}{})
|
||||
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, ®istrationToken)
|
||||
}{})
|
||||
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, ®istrationToken)
|
||||
}{})
|
||||
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
Reference in New Issue
Block a user