Files
forge/tests/integration/actions_job_token_test.go
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

476 lines
20 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
actions_model "github.com/hanzoai/git/models/actions"
auth_model "github.com/hanzoai/git/models/auth"
"github.com/hanzoai/git/models/db"
org_model "github.com/hanzoai/git/models/organization"
"github.com/hanzoai/git/models/perm"
repo_model "github.com/hanzoai/git/models/repo"
unit_model "github.com/hanzoai/git/models/unit"
"github.com/hanzoai/git/models/unittest"
user_model "github.com/hanzoai/git/models/user"
"github.com/hanzoai/git/modules/lfs"
"github.com/hanzoai/git/modules/structs"
"github.com/hanzoai/git/modules/util"
"github.com/hanzoai/git/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionsJobTokenPermissiveAccess(t *testing.T) {
cases := []struct {
name string
isFork bool
ownerPermMode repo_model.ActionsTokenPermissionMode
ownerMaxPerms map[unit_model.Type]perm.AccessMode
repoPermMode repo_model.ActionsTokenPermissionMode
repoMaxPerms map[unit_model.Type]perm.AccessMode
expectGitAccess perm.AccessMode
}{
{
name: "OwnerConfig-Permissive",
ownerPermMode: repo_model.ActionsTokenPermissionModePermissive,
expectGitAccess: perm.AccessModeWrite,
},
{
name: "OwnerConfig-Permissive-CodeNone",
ownerPermMode: repo_model.ActionsTokenPermissionModePermissive,
ownerMaxPerms: map[unit_model.Type]perm.AccessMode{unit_model.TypeCode: perm.AccessModeNone},
expectGitAccess: perm.AccessModeNone,
},
{
name: "OwnerConfig-Restricted",
ownerPermMode: repo_model.ActionsTokenPermissionModeRestricted,
expectGitAccess: perm.AccessModeRead,
},
// repo uses its own settings, so owner settings should not affect it
{
name: "SameRepo-Permissive",
ownerPermMode: repo_model.ActionsTokenPermissionModeRestricted,
ownerMaxPerms: map[unit_model.Type]perm.AccessMode{unit_model.TypeCode: perm.AccessModeNone},
repoPermMode: repo_model.ActionsTokenPermissionModePermissive,
expectGitAccess: perm.AccessModeWrite,
},
{
name: "SameRepo-Permissive-CodeNone",
ownerPermMode: repo_model.ActionsTokenPermissionModePermissive,
ownerMaxPerms: map[unit_model.Type]perm.AccessMode{unit_model.TypeCode: perm.AccessModeRead},
repoPermMode: repo_model.ActionsTokenPermissionModePermissive,
repoMaxPerms: map[unit_model.Type]perm.AccessMode{unit_model.TypeCode: perm.AccessModeNone},
expectGitAccess: perm.AccessModeNone,
},
{
name: "SameRepo-Restricted",
repoPermMode: repo_model.ActionsTokenPermissionModeRestricted,
expectGitAccess: perm.AccessModeRead,
},
// forks should be always restricted to max read access for code
{
name: "Fork-Permissive",
repoPermMode: repo_model.ActionsTokenPermissionModePermissive,
isFork: true,
expectGitAccess: perm.AccessModeRead,
},
{
name: "Fork-Restricted",
repoPermMode: repo_model.ActionsTokenPermissionModeRestricted,
isFork: true,
expectGitAccess: perm.AccessModeRead,
},
{
name: "Fork-Restricted-CodeNone",
repoPermMode: repo_model.ActionsTokenPermissionModeRestricted,
repoMaxPerms: map[unit_model.Type]perm.AccessMode{unit_model.TypeCode: perm.AccessModeNone},
isFork: true,
expectGitAccess: perm.AccessModeNone,
},
}
onGitRun(t, func(t *testing.T, u *url.URL) {
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 47})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: task.RepoID})
repoActionsUnit := repo.MustGetUnit(t.Context(), unit_model.TypeActions)
repoActionsCfg := repoActionsUnit.ActionsConfig()
ownerActionsCfg, err := actions_model.GetOwnerActionsConfig(t.Context(), repo.OwnerID)
require.NoError(t, err)
_, err = db.GetEngine(t.Context()).ID(task.RepoID).Cols("is_private").Update(&repo_model.Repository{IsPrivate: true})
require.NoError(t, err)
assertRespCodeForSuccess := func(t *testing.T, resp *httptest.ResponseRecorder, succeed bool) {
if succeed {
assert.True(t, 200 <= resp.Code && resp.Code < 300, "Expected success status code, got %d", resp.Code)
} else {
assert.True(t, 400 <= resp.Code && resp.Code < 500, "Expected client error status code, got %d", resp.Code)
}
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
// prepare owner's token permissions settings
ownerActionsCfg.TokenPermissionMode = tt.ownerPermMode
ownerActionsCfg.MaxTokenPermissions = util.Iif(tt.ownerMaxPerms == nil, nil, &repo_model.ActionsTokenPermissions{UnitAccessModes: tt.ownerMaxPerms})
require.NoError(t, actions_model.SetOwnerActionsConfig(t.Context(), repo.OwnerID, ownerActionsCfg))
// prepare repo's token permissions settings
repoActionsCfg.OverrideOwnerConfig = tt.repoPermMode != "" || tt.repoMaxPerms != nil
repoActionsCfg.TokenPermissionMode = tt.repoPermMode
repoActionsCfg.MaxTokenPermissions = util.Iif(tt.repoMaxPerms == nil, nil, &repo_model.ActionsTokenPermissions{UnitAccessModes: tt.repoMaxPerms})
require.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoActionsUnit))
// prepare task and its token
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")
require.NoError(t, err)
require.NoError(t, task.LoadJob(t.Context()))
require.NoError(t, task.Job.LoadRun(t.Context()))
task.Job.Run.IsForkPullRequest = tt.isFork
require.NoError(t, actions_model.UpdateRun(t.Context(), task.Job.Run, "is_fork_pull_request"))
testURL := *u
testURL.User = url.UserPassword("hanzo-actions", task.Token)
t.Run("ReadGitContent", func(t *testing.T) {
testURL.Path = "/user5/repo4.git/HEAD"
resp := MakeRequest(t, NewRequest(t, "GET", testURL.String()), NoExpectedStatus)
assertRespCodeForSuccess(t, resp, tt.expectGitAccess != perm.AccessModeNone)
testURL.Path = "/user5/repo4.git/info/lfs/locks"
req := NewRequest(t, "GET", testURL.String()).SetHeader("Accept", lfs.MediaType)
resp = MakeRequest(t, req, NoExpectedStatus)
assertRespCodeForSuccess(t, resp, tt.expectGitAccess != perm.AccessModeNone)
})
t.Run("WriteGitContent", func(t *testing.T) {
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/v1/repos/%s/contents/test-filename", repo.FullName()), &structs.CreateFileOptions{
FileOptions: structs.FileOptions{NewBranchName: "new-branch" + t.Name()},
ContentBase64: base64.StdEncoding.EncodeToString([]byte(`dummy content`)),
}).AddTokenAuth(task.Token)
resp := MakeRequest(t, req, NoExpectedStatus)
assertRespCodeForSuccess(t, resp, tt.expectGitAccess == perm.AccessModeWrite)
testURL.Path = "/user5/repo4.git/info/lfs/objects/batch"
req = NewRequestWithJSON(t, "POST", testURL.String(), lfs.BatchRequest{Operation: "upload"}).SetHeader("Accept", lfs.MediaType)
resp = MakeRequest(t, req, NoExpectedStatus)
assertRespCodeForSuccess(t, resp, tt.expectGitAccess == perm.AccessModeWrite)
})
t.Run("NoOtherPermissions", func(t *testing.T) {
req := NewRequest(t, "DELETE", "/v1/repos/"+repo.FullName()).AddTokenAuth(task.Token)
resp := MakeRequest(t, req, NoExpectedStatus)
assertRespCodeForSuccess(t, resp, false)
})
})
}
})
}
func TestActionsCrossRepoAccess(t *testing.T) {
onGitRun(t, func(t *testing.T, u *url.URL) {
session := loginUser(t, "user2")
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteOrganization)
// 1. Create Organization
orgName := "org-cross-test"
req := NewRequestWithJSON(t, "POST", "/v1/orgs", &structs.CreateOrgOption{
UserName: orgName,
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
owner, err := org_model.GetOrgByName(t.Context(), orgName)
require.NoError(t, err)
// 2. Create Two Repositories in owner
createRepoInOrg := func(name string) int64 {
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/v1/orgs/%s/repos", orgName), &structs.CreateRepoOption{
Name: name,
AutoInit: true,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
repo := DecodeJSON(t, resp, &structs.Repository{})
return repo.ID
}
repoAID := createRepoInOrg("repo-A")
repoBID := createRepoInOrg("repo-B")
// 3. Enable Actions in Repo A (Source) and Repo B (Target)
enableActions := func(repoID int64) {
err := db.Insert(t.Context(), &repo_model.RepoUnit{
RepoID: repoID,
Type: unit_model.TypeActions,
Config: &repo_model.ActionsConfig{
TokenPermissionMode: repo_model.ActionsTokenPermissionModePermissive,
},
})
require.NoError(t, err)
}
enableActions(repoAID)
enableActions(repoBID)
// 4. Create Task in Repo A, and use A's token to access B
taskA := createActionTask(t, repoAID, false)
testCtxA := APITestContext{
Session: emptyTestSession(t),
Token: taskA.Token,
Username: orgName,
Reponame: "repo-B",
}
testCtxA.ExpectedCode = http.StatusOK
t.Run("PublicCrossRepoAccess", doAPIGetRepository(testCtxA, func(t *testing.T, r structs.Repository) {
assert.Equal(t, "repo-B", r.Name)
}))
// make repo-B be private
req = NewRequestWithJSON(t, "PATCH", "/v1/repos/org-cross-test/repo-B", &structs.EditRepoOption{Private: new(true)}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusOK)
testCtxA.ExpectedCode = http.StatusNotFound
t.Run("NoPrivateCrossRepoAccess", doAPIGetRepository(testCtxA, nil))
ownerActionsCfg := actions_model.OwnerActionsConfig{AllowedCrossRepoIDs: []int64{repoBID}}
require.NoError(t, actions_model.SetOwnerActionsConfig(t.Context(), owner.ID, ownerActionsCfg))
testCtxA.ExpectedCode = http.StatusOK
t.Run("AccessToSelectedPrivateRepo", doAPIGetRepository(testCtxA, func(t *testing.T, r structs.Repository) {
assert.Equal(t, "repo-B", r.Name)
}))
t.Run("RepoTransfer", func(t *testing.T) {
ownerActionsCfg, err := actions_model.GetOwnerActionsConfig(t.Context(), owner.ID)
require.NoError(t, err)
assert.Contains(t, ownerActionsCfg.AllowedCrossRepoIDs, repoBID)
// Transfer Repository to user4
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/v1/repos/%s/repo-B/transfer", orgName), &structs.TransferRepoOption{
NewOwner: "user4",
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
// Accept transfer as user4
session4 := loginUser(t, "user4")
token4 := getTokenForLoggedInUser(t, session4, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
req = NewRequest(t, "POST", fmt.Sprintf("/v1/repos/%s/repo-B/transfer/accept", orgName)).AddTokenAuth(token4)
MakeRequest(t, req, http.StatusAccepted)
// Verify it is removed from the org's config
ownerActionsCfg, err = actions_model.GetOwnerActionsConfig(t.Context(), owner.ID)
require.NoError(t, err)
assert.NotContains(t, ownerActionsCfg.AllowedCrossRepoIDs, repoBID)
})
})
}
func TestActionsJobTokenPermissions(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("WriteIssue", TestActionsJobTokenPermissionsWriteIssue)
}
func TestActionsJobTokenPermissionsWriteIssue(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
require.Equal(t, repo.ID, task.RepoID)
require.NoError(t, db.Insert(t.Context(), &repo_model.RepoUnit{
RepoID: repo.ID,
Type: unit_model.TypeActions,
Config: &repo_model.ActionsConfig{},
}))
repoActionsUnit := repo.MustGetUnit(t.Context(), unit_model.TypeActions)
repoActionsCfg := repoActionsUnit.ActionsConfig()
repoActionsCfg.OverrideOwnerConfig = true
repoActionsCfg.TokenPermissionMode = repo_model.ActionsTokenPermissionModePermissive
repoActionsCfg.MaxTokenPermissions = nil
require.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoActionsUnit))
task.GenerateAndFillToken()
task.Status = actions_model.StatusRunning
require.NoError(t, actions_model.UpdateTask(t.Context(), task, "token_hash", "token_salt", "token_last_eight", "status"))
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue, auth_model.AccessTokenScopeWriteRepository)
labelURL := fmt.Sprintf("/v1/repos/%s/%s/labels", user.Name, repo.Name)
req := NewRequestWithJSON(t, "POST", labelURL, &structs.CreateLabelOption{
Name: "task-label",
Color: "0e8a16",
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
label := DecodeJSON(t, resp, &structs.Label{})
issueURL := fmt.Sprintf("/v1/repos/%s/%s/issues", user.Name, repo.Name)
req = NewRequestWithJSON(t, "POST", issueURL, &structs.CreateIssueOption{
Title: "issue for actions token label deletion",
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusCreated)
issue := DecodeJSON(t, resp, &structs.Issue{})
taskToken := task.Token
require.NotEmpty(t, taskToken)
issueLabelsURL := fmt.Sprintf("/v1/repos/%s/%s/issues/%d/labels", user.Name, repo.Name, issue.Index)
req = NewRequestWithJSON(t, "POST", issueLabelsURL, &structs.IssueLabelsOption{
Labels: []any{label.ID},
}).AddTokenAuth(taskToken)
MakeRequest(t, req, http.StatusOK)
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%d", issueLabelsURL, label.ID)).AddTokenAuth(taskToken)
MakeRequest(t, req, http.StatusNoContent)
}
func createActionTask(t *testing.T, repoID int64, isFork bool) *actions_model.ActionTask {
job := &actions_model.ActionRunJob{
RepoID: repoID,
Status: actions_model.StatusRunning,
IsForkPullRequest: isFork,
JobID: "test_job",
Name: "test_job",
}
require.NoError(t, db.Insert(t.Context(), job))
task := &actions_model.ActionTask{
JobID: job.ID,
RepoID: repoID,
Status: actions_model.StatusRunning,
IsForkPullRequest: isFork,
}
task.GenerateAndFillToken()
require.NoError(t, db.Insert(t.Context(), task))
return task
}
func TestActionsTokenPermissionsPersistenceWithWorkflow(t *testing.T) {
onGitRun(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)
// create repos
repo1 := createActionsTestRepo(t, token, "actions-permission-repo1", false)
repo2 := createActionsTestRepo(t, token, "actions-permission-repo2", true)
// add repo2 to owner-level cross-repo access list
req := NewRequestWithValues(t, "POST", "/user/settings/actions/general", map[string]string{
"cross_repo_add_target": "true",
"cross_repo_add_target_name": repo2.Name,
})
session.MakeRequest(t, req, http.StatusOK)
// create the runner for repo1
runner1 := newMockRunner()
runner1.registerAsRepoRunner(t, user2.Name, repo1.Name, "mock-runner", []string{"ubuntu-latest"}, false)
// set repo1 actions token permission mode to "permissive"
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/token_permissions", user2.Name, repo1.Name), map[string]string{
"token_permission_mode": "permissive",
"override_owner_config": "true",
})
session.MakeRequest(t, req, http.StatusSeeOther)
// set repo2 actions token permission mode to "restricted", and set max permissions
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/token_permissions", user2.Name, repo2.Name), map[string]string{
"token_permission_mode": "restricted",
"override_owner_config": "true",
"enable_max_permissions": "true",
"max_unit_access_mode_" + strconv.Itoa(int(unit_model.TypeReleases)): "read",
})
session.MakeRequest(t, req, http.StatusSeeOther)
// create a workflow file with "permission" keyword for repo1
wfTreePath := ".hanzo/workflows/test_permissions.yml"
wfFileContent := `name: Test Permissions
on:
push:
paths:
- '.hanzo/workflows/test_permissions.yml'
jobs:
job-override:
runs-on: ubuntu-latest
permissions:
code: write
steps:
- run: echo "test perms"
`
opts := getWorkflowCreateFileOptions(user2, repo1.DefaultBranch, "create "+wfTreePath, wfFileContent)
createWorkflowFile(t, token, user2.Name, repo1.Name, wfTreePath, opts)
task1 := runner1.fetchTask(t)
task1Token := valueOf(task1.Secrets, "GIT_TOKEN")
require.NotEmpty(t, task1Token)
// should fail: target repo does not allow code access
req = NewRequest(t, "GET", fmt.Sprintf("/v1/repos/%s/%s", user2.Name, repo2.Name)).AddTokenAuth(task1Token)
MakeRequest(t, req, http.StatusNotFound)
// set repo2 max permission to "read" so that the actions token can access code
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/token_permissions", user2.Name, repo2.Name), map[string]string{
"token_permission_mode": "restricted",
"override_owner_config": "true",
"enable_max_permissions": "true",
"max_unit_access_mode_" + strconv.Itoa(int(unit_model.TypeCode)): "read",
"max_unit_access_mode_" + strconv.Itoa(int(unit_model.TypeReleases)): "read",
})
session.MakeRequest(t, req, http.StatusSeeOther)
// should succeed: target repo now allows code read access for this token
req = NewRequest(t, "GET", fmt.Sprintf("/v1/repos/%s/%s", user2.Name, repo2.Name)).AddTokenAuth(task1Token)
MakeRequest(t, req, http.StatusOK)
// but it should not have write access
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/%s.git/info/lfs/objects/batch", user2.Name, repo2.Name), lfs.BatchRequest{Operation: "upload"}).
SetHeader("Accept", lfs.MediaType).
AddBasicAuth("hanzo-actions", task1Token)
MakeRequest(t, req, http.StatusUnauthorized)
// set repo1&repo2 max permission to "write" so that the actions token can access code
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/token_permissions", user2.Name, repo1.Name), map[string]string{
"token_permission_mode": "restricted",
"override_owner_config": "true",
"enable_max_permissions": "true",
"max_unit_access_mode_" + strconv.Itoa(int(unit_model.TypeCode)): "write",
})
session.MakeRequest(t, req, http.StatusSeeOther)
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/actions/general/token_permissions", user2.Name, repo2.Name), map[string]string{
"token_permission_mode": "restricted",
"override_owner_config": "true",
"enable_max_permissions": "true",
"max_unit_access_mode_" + strconv.Itoa(int(unit_model.TypeCode)): "write",
})
session.MakeRequest(t, req, http.StatusSeeOther)
// now task1 has write access to repo1, but still only read access to repo2 (different repo)
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/%s.git/info/lfs/objects/batch", user2.Name, repo1.Name), lfs.BatchRequest{Operation: "upload"}).
SetHeader("Accept", lfs.MediaType).
AddBasicAuth("hanzo-actions", task1Token)
MakeRequest(t, req, http.StatusOK)
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/%s.git/info/lfs/objects/batch", user2.Name, repo2.Name), lfs.BatchRequest{Operation: "upload"}).
SetHeader("Accept", lfs.MediaType).
AddBasicAuth("hanzo-actions", task1Token)
MakeRequest(t, req, http.StatusUnauthorized)
})
}