// SPDX-License-Identifier: MIT package actions import ( "encoding/binary" "fmt" "testing" "time" auth_model "forgejo.org/models/auth" "forgejo.org/models/db" "forgejo.org/models/repo" "forgejo.org/models/unittest" "forgejo.org/modules/timeutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestUpdateSecret checks that ActionRunner.UpdateSecret() sets the Token, // TokenSalt and TokenHash fields based on the specified token. func TestUpdateSecret(t *testing.T) { runner := ActionRunner{} token := "0123456789012345678901234567890123456789" err := runner.UpdateSecret(token) require.NoError(t, err) assert.Equal(t, token, runner.Token) assert.Regexp(t, "^[0-9a-f]{32}$", runner.TokenSalt) assert.Equal(t, runner.TokenHash, auth_model.HashToken(token, runner.TokenSalt)) } func TestDeleteRunner(t *testing.T) { const recordID = 12345678 require.NoError(t, unittest.PrepareTestDatabase()) before := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: recordID}) err := DeleteRunner(db.DefaultContext, &ActionRunner{ID: recordID}) require.NoError(t, err) var after ActionRunner found, err := db.GetEngine(db.DefaultContext).ID(recordID).Unscoped().Get(&after) require.NoError(t, err) assert.True(t, found) // Most fields (namely Name, Version, OwnerID, RepoID, Description, Base, RepoRange, // TokenHash, TokenSalt, LastOnline, LastActive, AgentLabels and Created) are unaffected assert.Equal(t, before.Name, after.Name) assert.Equal(t, before.Version, after.Version) assert.Equal(t, before.OwnerID, after.OwnerID) assert.Equal(t, before.RepoID, after.RepoID) assert.Equal(t, before.Description, after.Description) assert.Equal(t, before.Base, after.Base) assert.Equal(t, before.RepoRange, after.RepoRange) assert.Equal(t, before.TokenHash, after.TokenHash) assert.Equal(t, before.TokenSalt, after.TokenSalt) assert.Equal(t, before.LastOnline, after.LastOnline) assert.Equal(t, before.LastActive, after.LastActive) assert.Equal(t, before.AgentLabels, after.AgentLabels) assert.Equal(t, before.Created, after.Created) // Deleted contains a value assert.NotNil(t, after.Deleted) // UUID was modified assert.NotEqual(t, before.UUID, after.UUID) // UUID starts with ffffffff-ffff-ffff- assert.Equal(t, "ffffffff-ffff-ffff-", after.UUID[:19]) // UUID ends with LE binary representation of record ID idAsBinary := make([]byte, 8) binary.LittleEndian.PutUint64(idAsBinary, uint64(recordID)) idAsHexadecimal := fmt.Sprintf("%.2x%.2x-%.2x%.2x%.2x%.2x%.2x%.2x", idAsBinary[0], idAsBinary[1], idAsBinary[2], idAsBinary[3], idAsBinary[4], idAsBinary[5], idAsBinary[6], idAsBinary[7]) assert.Equal(t, idAsHexadecimal, after.UUID[19:]) } func TestDeleteOfflineRunnersRunnerGlobalOnly(t *testing.T) { baseTime := time.Date(2024, 5, 19, 7, 40, 32, 0, time.UTC) timeutil.MockSet(baseTime) defer timeutil.MockUnset() require.NoError(t, unittest.PrepareTestDatabase()) olderThan := timeutil.TimeStampNow().Add(-timeutil.Hour) require.NoError(t, DeleteOfflineRunners(db.DefaultContext, olderThan, true)) // create at test base time unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 12345678}) // last_online test base time unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000001}) // created one month ago but a repo unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000002}) // last online one hour ago unittest.AssertNotExistsBean(t, &ActionRunner{ID: 10000003}) // last online 10 seconds ago unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000004}) // created 1 month ago unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000005}) // created 1 hour ago unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000006}) // last online 1 hour ago unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000007}) } func TestDeleteOfflineRunnersAll(t *testing.T) { baseTime := time.Date(2024, 5, 19, 7, 40, 32, 0, time.UTC) timeutil.MockSet(baseTime) defer timeutil.MockUnset() require.NoError(t, unittest.PrepareTestDatabase()) olderThan := timeutil.TimeStampNow().Add(-timeutil.Hour) require.NoError(t, DeleteOfflineRunners(db.DefaultContext, olderThan, false)) // create at test base time unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 12345678}) // last_online test base time unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000001}) // created one month ago unittest.AssertNotExistsBean(t, &ActionRunner{ID: 10000002}) // last online one hour ago unittest.AssertNotExistsBean(t, &ActionRunner{ID: 10000003}) // last online 10 seconds ago unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000004}) // created 1 month ago unittest.AssertNotExistsBean(t, &ActionRunner{ID: 10000005}) // created 1 hour ago unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000006}) // last online 1 hour ago unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000007}) } func TestDeleteOfflineRunnersErrorOnInvalidOlderThanValue(t *testing.T) { baseTime := time.Date(2024, 5, 19, 7, 40, 32, 0, time.UTC) timeutil.MockSet(baseTime) defer timeutil.MockUnset() require.Error(t, DeleteOfflineRunners(db.DefaultContext, timeutil.TimeStampNow(), false)) } func TestRunnerEditable(t *testing.T) { testCases := []struct { name string runner *ActionRunner ownerID int64 repoID int64 editable bool }{ { name: "admin-can-edit-global-runner", runner: &ActionRunner{Name: "global-runner", OwnerID: 0, RepoID: 0}, ownerID: 0, repoID: 0, editable: true, }, { name: "admin-can-edit-user-runner", runner: &ActionRunner{Name: "user-runner", OwnerID: 36, RepoID: 0}, ownerID: 0, repoID: 0, editable: true, }, { name: "admin-can-edit-repository-runner", runner: &ActionRunner{Name: "user-runner", OwnerID: 0, RepoID: 110}, ownerID: 0, repoID: 0, editable: true, }, { name: "user-can-edit-its-runner", runner: &ActionRunner{Name: "user-runner", OwnerID: 469, RepoID: 0}, ownerID: 469, repoID: 0, editable: true, }, { name: "user-cannot-edit-global-runner", runner: &ActionRunner{Name: "global-runner", OwnerID: 0, RepoID: 0}, ownerID: 469, repoID: 0, editable: false, }, { name: "user-cannot-edit-other-users-runner", runner: &ActionRunner{Name: "user-runner", OwnerID: 892, RepoID: 0}, ownerID: 469, repoID: 0, editable: false, }, { name: "user-cannot-edit-repo-runner", runner: &ActionRunner{Name: "repo-runner", OwnerID: 0, RepoID: 151}, ownerID: 469, repoID: 0, editable: false, }, { name: "repo-can-edit-its-runner", runner: &ActionRunner{Name: "repo-runner", OwnerID: 0, RepoID: 693}, ownerID: 0, repoID: 693, editable: true, }, { name: "repo-cannot-edit-other-repo-runner", runner: &ActionRunner{Name: "repo-runner", OwnerID: 0, RepoID: 519}, ownerID: 0, repoID: 693, editable: false, }, { name: "repo-cannot-edit-global-runner", runner: &ActionRunner{Name: "global-runner", OwnerID: 0, RepoID: 0}, ownerID: 0, repoID: 693, editable: false, }, { name: "repo-cannot-edit-user-runner", runner: &ActionRunner{Name: "user-runner", OwnerID: 6, RepoID: 0}, ownerID: 0, repoID: 693, editable: false, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { result := testCase.runner.Editable(testCase.ownerID, testCase.repoID) assert.Equal(t, testCase.editable, result) }) } } func TestRunner_GetVisibleRunnerByID(t *testing.T) { defer unittest.OverrideFixtures("models/actions/TestRunner_GetVisibleRunnerByID")() require.NoError(t, unittest.PrepareTestDatabase()) repository32 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 32, OwnerID: 3}) repository1 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1, OwnerID: 2}) runner1 := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 719931, OwnerID: 3, RepoID: 0}) // Owned by org3 runner2 := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 719932, OwnerID: 2, RepoID: 0}) // Owned by user2 runner3 := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 719933, OwnerID: 0, RepoID: 0}) runner4 := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 719934, OwnerID: 0, RepoID: repository32.ID}) testCases := []struct { name string runner *ActionRunner ownerID int64 repoID int64 expectedError string }{ { name: "Organization runner", runner: runner1, ownerID: 3, repoID: 0, expectedError: "", }, { name: "Organization runner visible to admins", runner: runner1, ownerID: 0, repoID: 0, expectedError: "", }, { name: "Organization runner invisible to different owner", runner: runner1, ownerID: 2, repoID: 0, expectedError: fmt.Sprintf("runner with ID %d: resource does not exist", runner1.ID), }, { name: "Organization runner visible to its repositories", runner: runner1, ownerID: 0, repoID: repository32.ID, expectedError: "", }, { name: "Organization runner invisible to repositories owned by somebody else", runner: runner1, ownerID: 0, repoID: repository1.ID, expectedError: fmt.Sprintf("runner with ID %d: resource does not exist", runner1.ID), }, { name: "User runner", runner: runner2, ownerID: 2, repoID: 0, expectedError: "", }, { name: "User runner invisible to different user", runner: runner2, ownerID: 1, repoID: 0, expectedError: fmt.Sprintf("runner with ID %d: resource does not exist", runner2.ID), }, { name: "User runner visible to repository owned by user", runner: runner2, ownerID: 0, repoID: repository1.ID, expectedError: "", }, { name: "User runner invisible to repository owned by different user", runner: runner2, ownerID: 0, repoID: repository32.ID, expectedError: fmt.Sprintf("runner with ID %d: resource does not exist", runner2.ID), }, { name: "Global runner", runner: runner3, ownerID: 0, repoID: 0, expectedError: "", }, { name: "Global runner is visible to any user", runner: runner3, ownerID: 2, repoID: 0, expectedError: "", }, { name: "Global runner is visible to any repository", runner: runner3, ownerID: 0, repoID: repository32.ID, expectedError: "", }, { name: "Repository runner", runner: runner4, ownerID: 0, repoID: repository32.ID, expectedError: "", }, { name: "Repository runner is visible to admins", runner: runner4, ownerID: 0, repoID: 0, expectedError: "", }, { name: "Repository runner is invisible to repository owner", runner: runner4, ownerID: repository32.OwnerID, repoID: 0, expectedError: fmt.Sprintf("runner with ID %d: resource does not exist", runner4.ID), }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { _, err := GetVisibleRunnerByID(t.Context(), testCase.runner.ID, testCase.ownerID, testCase.repoID) if testCase.expectedError == "" { require.NoError(t, err) } else { assert.ErrorContains(t, err, testCase.expectedError) } }) } } func TestRunner_FindRunnerOptionsToConds(t *testing.T) { defer unittest.OverrideFixtures("models/actions/TestRunner_FindRunnerOptionsToConds")() require.NoError(t, unittest.PrepareTestDatabase()) runner1 := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 719931, OwnerID: 3, RepoID: 0}) // Owned by org3 runner2 := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 719932, OwnerID: 2, RepoID: 0}) // Owned by user2 runner3 := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 719933, OwnerID: 0, RepoID: 0}) runner4 := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 719934, OwnerID: 0, RepoID: 32}) runner5 := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 719935, OwnerID: 0, RepoID: 36}) testCases := []struct { name string opts FindRunnerOptions expectedRunners RunnerList unexpectedRunners RunnerList }{ { name: "Only runners owned by instance", opts: FindRunnerOptions{OwnerID: 0, RepoID: 0, WithVisible: false}, expectedRunners: RunnerList{runner3}, unexpectedRunners: RunnerList{runner1, runner2, runner4, runner5}, }, { name: "All runners on instance", opts: FindRunnerOptions{OwnerID: 0, RepoID: 0, WithVisible: true}, expectedRunners: RunnerList{runner1, runner2, runner3, runner4, runner5}, unexpectedRunners: RunnerList{}, }, { name: "Only runners owned by organization", opts: FindRunnerOptions{OwnerID: 3, RepoID: 0, WithVisible: false}, expectedRunners: RunnerList{runner1}, unexpectedRunners: RunnerList{runner2, runner3, runner4, runner5}, }, { name: "Runners available to organization", opts: FindRunnerOptions{OwnerID: 3, RepoID: 0, WithVisible: true}, expectedRunners: RunnerList{runner1, runner3}, unexpectedRunners: RunnerList{runner2, runner4, runner5}, }, { name: "Only runners owned by user", opts: FindRunnerOptions{OwnerID: 2, RepoID: 0, WithVisible: false}, expectedRunners: RunnerList{runner2}, unexpectedRunners: RunnerList{runner1, runner3, runner4, runner5}, }, { name: "Runners available to user", opts: FindRunnerOptions{OwnerID: 2, RepoID: 0, WithVisible: true}, expectedRunners: RunnerList{runner2, runner3}, unexpectedRunners: RunnerList{runner1, runner4, runner5}, }, { name: "Only runners owned by organization repository", opts: FindRunnerOptions{OwnerID: 0, RepoID: 32, WithVisible: false}, expectedRunners: RunnerList{runner4}, unexpectedRunners: RunnerList{runner1, runner2, runner3, runner5}, }, { name: "Runners available to organization repository", opts: FindRunnerOptions{OwnerID: 0, RepoID: 32, WithVisible: true}, expectedRunners: RunnerList{runner1, runner3, runner4}, unexpectedRunners: RunnerList{runner2, runner5}, }, { name: "Only runners owned by user repository", opts: FindRunnerOptions{OwnerID: 0, RepoID: 36, WithVisible: false}, expectedRunners: RunnerList{runner5}, unexpectedRunners: RunnerList{runner1, runner2, runner3, runner4}, }, { name: "Runners available to user repository", opts: FindRunnerOptions{OwnerID: 0, RepoID: 36, WithVisible: true}, expectedRunners: RunnerList{runner2, runner3, runner5}, unexpectedRunners: RunnerList{runner1, runner4}, }, { name: "Runners with partially matching name", opts: FindRunnerOptions{Filter: "er-3"}, expectedRunners: RunnerList{runner3}, unexpectedRunners: RunnerList{runner1, runner2, runner4, runner5}, }, { name: "Runners with partially matching UUID", opts: FindRunnerOptions{Filter: "21f75233798b"}, expectedRunners: RunnerList{runner4}, unexpectedRunners: RunnerList{runner1, runner2, runner3, runner5}, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { runners, err := db.Find[ActionRunner](t.Context(), testCase.opts) require.NoError(t, err) for _, expectedRunner := range testCase.expectedRunners { assert.Contains(t, runners, expectedRunner) } for _, unexpectedRunner := range testCase.unexpectedRunners { assert.NotContains(t, runners, unexpectedRunner) } }) } }