// Copyright 2022 The Gitea Authors. All rights reserved. // Copyright 2020 The nektos/act Authors. All rights reserved. // SPDX-License-Identifier: MIT package runner import ( "bytes" "context" "errors" "fmt" "io" "reflect" "strings" "sync" "testing" "time" "gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common/logging/logtest" "gitea.com/gitea/runner/act/container" "gitea.dev/actionslib/pkg/exprparser" "gitea.dev/actionslib/pkg/model" assert "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" yaml "go.yaml.in/yaml/v4" ) func createRunContext(t *testing.T) *RunContext { var yml yaml.Node err := yml.Encode(map[string][]any{ "os": {"Linux", "Windows"}, "foo": {"bar", "baz"}, }) require.NoError(t, err) return &RunContext{ Config: &Config{ Env: testCheckoutEnv(), Workdir: ".", Secrets: map[string]string{ "CASE_INSENSITIVE_SECRET": "value", }, Vars: map[string]string{ "CASE_INSENSITIVE_VAR": "value", }, }, Env: map[string]string{ "key": "value", }, Run: &model.Run{ JobID: "job1", Workflow: &model.Workflow{ Name: "test-workflow", Jobs: map[string]*model.Job{ "job1": { Strategy: &model.Strategy{ RawMatrix: yml, }, }, }, }, }, Matrix: map[string]any{ "os": "Linux", "foo": "bar", }, StepResults: map[string]*model.StepResult{ "idwithnothing": { Conclusion: model.StepStatusSuccess, Outcome: model.StepStatusFailure, Outputs: map[string]string{ "foowithnothing": "barwithnothing", }, }, "id-with-hyphens": { Conclusion: model.StepStatusSuccess, Outcome: model.StepStatusFailure, Outputs: map[string]string{ "foo-with-hyphens": "bar-with-hyphens", }, }, "id_with_underscores": { Conclusion: model.StepStatusSuccess, Outcome: model.StepStatusFailure, Outputs: map[string]string{ "foo_with_underscores": "bar_with_underscores", }, }, }, } } func TestEvaluateRunContext(t *testing.T) { rc := createRunContext(t) ee := rc.NewExpressionEvaluator(t.Context()) tables := []struct { in string out any }{ {" 1 ", 1.0}, {"'my text'", "my text"}, {"contains('my text', 'te')", true}, {"contains('my TEXT', 'te')", true}, {"contains(fromJSON('[\"my text\"]'), 'te')", false}, {"contains(fromJSON('[\"foo\",\"bar\"]'), 'bar')", true}, {"startsWith('hello world', 'He')", true}, {"endsWith('hello world', 'ld')", true}, {"format('0:{0} 2:{2} 1:{1}', 'zero', 'one', 'two')", "0:zero 2:two 1:one"}, {"join(fromJSON('[\"hello\"]'),'octocat')", "hello"}, {"join(fromJSON('[\"hello\",\"mona\",\"the\"]'),'octocat')", "hellooctocatmonaoctocatthe"}, {"join('hello','mona')", "hello"}, {"toJSON(env)", "{\n \"ACT\": \"true\",\n \"ACT_SKIP_CHECKOUT\": \"true\",\n \"key\": \"value\"\n}"}, {"toJson(env)", "{\n \"ACT\": \"true\",\n \"ACT_SKIP_CHECKOUT\": \"true\",\n \"key\": \"value\"\n}"}, {"(fromJSON('{\"foo\":\"bar\"}')).foo", "bar"}, {"(fromJson('{\"foo\":\"bar\"}')).foo", "bar"}, {"(fromJson('[\"foo\",\"bar\"]'))[1]", "bar"}, {"hashFiles('**/non-extant-files')", ""}, {"hashFiles('**/non-extant-files', '**/more-non-extant-files')", ""}, {"hashFiles('**/non.extant.files')", ""}, {"hashFiles('**/non''extant''files')", ""}, {"success()", true}, {"failure()", false}, {"always()", true}, {"cancelled()", false}, {"github.workflow", "test-workflow"}, {"github.actor", "nektos/act"}, {"github.run_id", "1"}, {"github.run_number", "1"}, {"job.status", "success"}, {"matrix.os", "Linux"}, {"matrix.foo", "bar"}, {"env.key", "value"}, {"secrets.CASE_INSENSITIVE_SECRET", "value"}, {"secrets.case_insensitive_secret", "value"}, {"vars.CASE_INSENSITIVE_VAR", "value"}, {"vars.case_insensitive_var", "value"}, {"format('{{0}}', 'test')", "{0}"}, {"format('{{{0}}}', 'test')", "{test}"}, {"format('}}')", "}"}, {"format('echo Hello {0} ${{Test}}', 'World')", "echo Hello World ${Test}"}, {"format('echo Hello {0} ${{Test}}', github.undefined_property)", "echo Hello ${Test}"}, {"format('echo Hello {0}{1} ${{Te{0}st}}', github.undefined_property, 'World')", "echo Hello World ${Test}"}, {"format('{0}', '{1}', 'World')", "{1}"}, {"format('{{{0}', '{1}', 'World')", "{{1}"}, } for _, table := range tables { t.Run(table.in, func(t *testing.T) { out, err := ee.evaluate(t.Context(), table.in, exprparser.DefaultStatusCheckNone) require.NoError(t, err) assert.Equal(t, table.out, out) }) } } type hashFilesContainer struct { fakeContainer stdout, stderr io.Writer copyErr error execute func([]string, map[string]string, string, string) common.Executor } func (c *hashFilesContainer) Copy(_destPath string, _files ...*container.FileEntry) common.Executor { return common.NewErrorExecutor(c.copyErr) } func (c *hashFilesContainer) Exec(command []string, env map[string]string, user, workdir string) common.Executor { return c.execute(command, env, user, workdir) } func (c *hashFilesContainer) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writer, io.Writer) { previousOut, previousErr := c.stdout, c.stderr c.stdout, c.stderr = stdout, stderr return previousOut, previousErr } func TestGetHashFilesFunction(t *testing.T) { const failureMessage = "hashFiles('dir''file, *.sum') failed. Fail to hash files under directory '/workspace'" for _, test := range []struct { name string output string execErr error copyErr error hash string err string warning bool expired bool }{ {name: "success", output: "patterns __OUTPUT__wrong__OUTPUT__ echoed\n__OUTPUT__digest__OUTPUT__", hash: "digest"}, {name: "no matches", output: "__OUTPUT____OUTPUT__"}, {name: "script failure", output: "Cannot find: node in PATH\n__OUTPUT__digest__OUTPUT__", execErr: container.ExitCodeError(1), err: failureMessage}, {name: "timeout", execErr: context.DeadlineExceeded, err: "hashFiles('dir''file, *.sum') couldn't finish within 120 seconds"}, {name: "job deadline", execErr: context.DeadlineExceeded, expired: true, err: context.DeadlineExceeded.Error()}, {name: "node missing", execErr: fmt.Errorf("wrapped: %w", container.ExitCodeError(127)), warning: true}, {name: "node not executable", execErr: container.ExitCodeError(126), warning: true}, {name: "host node missing", execErr: errors.New("Cannot find: node in PATH"), warning: true}, {name: "copy failure", copyErr: errors.New("open /runner/cache: permission denied"), err: failureMessage}, } { t.Run(test.name, func(t *testing.T) { logger, hook := logtest.NewLogger() stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} jobContainer := &hashFilesContainer{stdout: stdout, stderr: stderr, copyErr: test.copyErr} jobContainer.execute = func(command []string, env map[string]string, user, workdir string) common.Executor { assert.Equal(t, []string{"node", "/var/run/act/workflow/hashfiles/index.js"}, command) assert.Equal(t, map[string]string{"patterns": "dir'file\n*.sum", "followSymbolicLinks": "true"}, env) assert.Empty(t, user) assert.Empty(t, workdir) return func(_ctx context.Context) error { _, err := io.WriteString(jobContainer.stderr, test.output) require.NoError(t, err) return test.execErr } } ctx := common.WithLogger(t.Context(), logger) if test.expired { var cancel context.CancelFunc ctx, cancel = context.WithDeadline(ctx, time.Now()) defer cancel() } result, err := getHashFilesFunction(ctx, &RunContext{ Config: &Config{Workdir: "/workspace"}, JobContainer: jobContainer, })([]reflect.Value{reflect.ValueOf("--follow-symbolic-links"), reflect.ValueOf("dir'file"), reflect.ValueOf("*.sum")}) if test.err == "" { require.NoError(t, err) } else { require.EqualError(t, err, test.err) } assert.Equal(t, test.hash, result) assert.Same(t, stdout, jobContainer.stdout) assert.Same(t, stderr, jobContainer.stderr) if test.warning { require.Len(t, hook.AllRecords(), 1) assert.Equal(t, "##[warning]hashFiles could not run because node is unavailable in the job environment. Returning an empty string.", hook.LastRecord().Message) } else { assert.Empty(t, hook.AllRecords()) } }) } } func TestEvaluateStep(t *testing.T) { rc := createRunContext(t) rc.Env["INPUT_FORGED"] = "leaked" step := &stepRun{ RunContext: rc, env: map[string]string{"INPUT_FORGED": "leaked"}, } ee := rc.NewStepExpressionEvaluator(t.Context(), step) tables := []struct { in string out any }{ {"steps.idwithnothing.conclusion", model.StepStatusSuccess.String()}, {"steps.idwithnothing.outcome", model.StepStatusFailure.String()}, {"steps.idwithnothing.outputs.foowithnothing", "barwithnothing"}, {"steps.id-with-hyphens.conclusion", model.StepStatusSuccess.String()}, {"steps.id-with-hyphens.outcome", model.StepStatusFailure.String()}, {"steps.id-with-hyphens.outputs.foo-with-hyphens", "bar-with-hyphens"}, {"steps.id_with_underscores.conclusion", model.StepStatusSuccess.String()}, {"steps.id_with_underscores.outcome", model.StepStatusFailure.String()}, {"steps.id_with_underscores.outputs.foo_with_underscores", "bar_with_underscores"}, {"inputs.forged", nil}, } for _, table := range tables { t.Run(table.in, func(t *testing.T) { out, err := ee.evaluate(t.Context(), table.in, exprparser.DefaultStatusCheckNone) require.NoError(t, err) assert.Equal(t, table.out, out) }) } } func TestInterpolate(t *testing.T) { rc := &RunContext{ Config: &Config{ Env: testCheckoutEnv(), Workdir: ".", Secrets: map[string]string{ "CASE_INSENSITIVE_SECRET": "value", }, Vars: map[string]string{ "CASE_INSENSITIVE_VAR": "value", }, }, Env: map[string]string{ "KEYWITHNOTHING": "valuewithnothing", "KEY-WITH-HYPHENS": "value-with-hyphens", "KEY_WITH_UNDERSCORES": "value_with_underscores", "SOMETHING_TRUE": "true", "SOMETHING_FALSE": "false", }, Run: &model.Run{ JobID: "job1", Workflow: &model.Workflow{ Name: "test-workflow", Jobs: map[string]*model.Job{ "job1": {}, }, }, }, } ee := rc.NewExpressionEvaluator(t.Context()) tables := []struct { in string out string }{ {" text ", " text "}, {" $text ", " $text "}, {" ${text} ", " ${text} "}, {" ${{ 1 }} to ${{2}} ", " 1 to 2 "}, {" ${{ (true || false) }} to ${{2}} ", " true to 2 "}, {" ${{ (false || '}}' ) }} to ${{2}} ", " }} to 2 "}, {" ${{ env.KEYWITHNOTHING }} ", " valuewithnothing "}, {" ${{ env.KEY-WITH-HYPHENS }} ", " value-with-hyphens "}, {" ${{ env.KEY_WITH_UNDERSCORES }} ", " value_with_underscores "}, {"${{ secrets.CASE_INSENSITIVE_SECRET }}", "value"}, {"${{ secrets.case_insensitive_secret }}", "value"}, {"${{ vars.CASE_INSENSITIVE_VAR }}", "value"}, {"${{ vars.case_insensitive_var }}", "value"}, {"${{ env.UNKNOWN }}", ""}, {"${{ env.SOMETHING_TRUE }}", "true"}, {"${{ env.SOMETHING_FALSE }}", "false"}, {"${{ !env.SOMETHING_TRUE }}", "false"}, {"${{ !env.SOMETHING_FALSE }}", "false"}, {"${{ !env.SOMETHING_TRUE && true }}", "false"}, {"${{ !env.SOMETHING_FALSE && true }}", "false"}, {"${{ env.SOMETHING_TRUE && true }}", "true"}, {"${{ env.SOMETHING_FALSE && true }}", "true"}, {"${{ !env.SOMETHING_TRUE || true }}", "true"}, {"${{ !env.SOMETHING_FALSE || true }}", "true"}, {"${{ !env.SOMETHING_TRUE && false }}", "false"}, {"${{ !env.SOMETHING_FALSE && false }}", "false"}, {"${{ !env.SOMETHING_TRUE || false }}", "false"}, {"${{ !env.SOMETHING_FALSE || false }}", "false"}, {"${{ env.SOMETHING_TRUE || false }}", "true"}, {"${{ env.SOMETHING_FALSE || false }}", "false"}, {"${{ env.SOMETHING_FALSE }} && ${{ env.SOMETHING_TRUE }}", "false && true"}, {"${{ fromJSON('{}') < 2 }}", "false"}, {"${{ 1 }}", "1"}, {"${{ 1.0 }}", "1"}, {"${{ null }}", ""}, {"${{ fromJSON('[1,2]') }}", "Array"}, {"${{ fromJSON('{\"a\":1}') }}", "Object"}, } for _, table := range tables { t.Run("interpolate", func(t *testing.T) { out, err := ee.Interpolate(t.Context(), table.in) require.NoError(t, err, table.in) assert.Equal(t, table.out, out, table.in) }) } for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 1"} { _, err := ee.Interpolate(t.Context(), in) assert.Error(t, err, in) } } func TestGetEvaluatorInputsBoolean(t *testing.T) { workflow := func(event string) string { return "on:\n " + event + `: inputs: flag: type: boolean default: true name: type: string default: gitea unset: type: boolean ` } tables := []struct { name string event map[string]any flag any }{ { name: "native bool true", event: map[string]any{"inputs": map[string]any{"flag": true}}, flag: true, }, { name: "native bool false", event: map[string]any{"inputs": map[string]any{"flag": false}}, flag: false, }, { name: "string true", event: map[string]any{"inputs": map[string]any{"flag": "true"}}, flag: true, }, { name: "string false", event: map[string]any{"inputs": map[string]any{"flag": "false"}}, flag: false, }, { name: "default is used when the event carries no inputs", event: map[string]any{}, flag: true, }, } for _, eventName := range []string{"workflow_call", "workflow_dispatch"} { for _, table := range tables { t.Run(eventName+"/"+table.name, func(t *testing.T) { wf, err := model.ReadWorkflow(strings.NewReader(workflow(eventName))) require.NoError(t, err) rc := &RunContext{ Config: &Config{Workdir: "."}, Run: &model.Run{JobID: "job1", Workflow: wf}, } ghc := &model.GithubContext{EventName: eventName, Event: table.event} inputs := getEvaluatorInputs(rc, nil, ghc) assert.Equal(t, table.flag, inputs["flag"]) assert.Equal(t, "gitea", inputs["name"]) assert.Equal(t, false, inputs["unset"]) }) } } t.Run("deferred workflow call inputs", func(t *testing.T) { workflow, err := model.ReadWorkflow(strings.NewReader(workflow("workflow_call") + ` jobs: test: {} call: uses: ./reuse.yml with: ${{ fromJSON(matrix.args) }} `)) require.NoError(t, err) workflow.GetJob("test") job := workflow.GetJob("call") rawWith := model.CloneYamlNode(job.RawWith) for _, value := range []string{"true", "false"} { t.Run(value, func(t *testing.T) { t.Parallel() parent, err := (&runnerImpl{config: &Config{PresetGitHubContext: &model.GithubContext{}}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "call"}, map[string]any{"args": `{"flag":` + value + `,"name":"${{ 'runner' }}"}`}) require.NoError(t, err) child, err := (&runnerImpl{config: &Config{PresetGitHubContext: &model.GithubContext{}}, caller: &caller{runContext: parent}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil) require.NoError(t, err) assert.Equal(t, map[string]any{"flag": value == "true", "name": "${{ 'runner' }}", "unset": false}, child.workflowCallInputs) assert.Equal(t, rawWith, job.RawWith) assert.Nil(t, job.With) }) } }) } func TestGetEvaluatorInputsResolvedByGitea(t *testing.T) { wf, err := model.ReadWorkflow(strings.NewReader(` on: workflow_call: inputs: name: {type: string, default: callee} workflow_dispatch: inputs: name: {type: string} `)) require.NoError(t, err) config := &Config{Workdir: ".", WorkflowCallInputs: map[string]any{"name": "caller", "target": "dispatch"}} ghc := &model.GithubContext{EventName: "workflow_dispatch", Event: map[string]any{"inputs": map[string]any{"name": "dispatch"}}} job := &RunContext{Config: config, Run: &model.Run{JobID: "job1", Workflow: wf}} assert.Equal(t, map[string]any{"name": "caller", "target": "dispatch"}, getEvaluatorInputs(job, nil, ghc)) composite := &RunContext{Config: config, Run: &model.Run{JobID: "job1", Workflow: &model.Workflow{}}, Parent: job} assert.Equal(t, map[string]any{"arg": "x"}, getEvaluatorInputs(composite, map[string]any{"arg": "x"}, ghc)) } func TestActionInputsShadowDispatchInputs(t *testing.T) { wf, err := model.ReadWorkflow(strings.NewReader(` on: workflow_dispatch: inputs: message: {type: string} jobs: job1: {} `)) require.NoError(t, err) rc := newTestRC(wf, nil) rc.Config.EventName = "workflow_dispatch" rc.EventJSON = `{"inputs":{"message":"workflow"}}` step := &stepActionRemote{RunContext: rc, env: map[string]string{"INPUT_MESSAGE": "step"}} assert.Equal(t, "step", rc.NewActionInputsExpressionEvaluator(t.Context(), step).InterpolateName(t.Context(), "${{ inputs.message }}")) assert.Equal(t, "workflow", rc.NewStepExpressionEvaluator(t.Context(), step).InterpolateName(t.Context(), "${{ inputs.message }}")) enabled, err := isStepEnabled(t.Context(), "inputs.message == 'workflow'", step, stepStageMain) require.NoError(t, err) assert.True(t, enabled) step.Step, step.action = &model.Step{}, &model.Action{Inputs: map[string]model.Input{"fallback": {Default: "${{ inputs.message }}"}}} env, err := evaluateCompositeInputAndEnv(t.Context(), rc, step) require.NoError(t, err) assert.Equal(t, "workflow", env["INPUT_FALLBACK"]) } func TestResolveWorkflowCallToleratesMismatchesAndPassesInheritedSecretsAndRuntimeToken(t *testing.T) { callerWorkflow, err := model.ReadWorkflow(strings.NewReader(` jobs: call: uses: ./reuse.yml with: {flag: not-a-boolean} secrets: {undeclared: "${{ secrets.token }}"} inherit: uses: ./reuse.yml secrets: inherit `)) require.NoError(t, err) callee, err := model.ReadWorkflow(strings.NewReader(` on: workflow_call: inputs: flag: {type: boolean, default: true} jobs: test: {} `)) require.NoError(t, err) notCallable, err := model.ReadWorkflow(strings.NewReader("on: push\njobs:\n test: {}\n")) require.NoError(t, err) config := &Config{ Secrets: map[string]string{"token": "${{ github.token }}"}, PresetGitHubContext: &model.GithubContext{}, RuntimeTokens: map[*model.Workflow]string{callerWorkflow: "caller-run"}, } resolve := func(callerJobID string, workflow *model.Workflow) *RunContext { parent, err := (&runnerImpl{config: config}).newRunContext(t.Context(), &model.Run{Workflow: callerWorkflow, JobID: callerJobID}, nil) require.NoError(t, err) child, err := (&runnerImpl{config: config, caller: &caller{runContext: parent}}).newRunContext(t.Context(), &model.Run{Workflow: workflow, JobID: "test"}, nil) require.NoError(t, err) return child } called := resolve("call", callee) assert.Equal(t, map[string]any{"flag": true}, called.workflowCallInputs) assert.Equal(t, map[string]string{"undeclared": "${{ github.token }}"}, called.workflowCallSecrets) assert.Equal(t, "caller-run", called.GetEnv()["ACTIONS_RUNTIME_TOKEN"]) inherited := resolve("inherit", notCallable) assert.Empty(t, inherited.workflowCallInputs) assert.Equal(t, map[string]string{"token": "${{ github.token }}", "ACTIONS_RUNTIME_TOKEN": "caller-run"}, inherited.workflowCallSecrets) } func TestJobNameMasksSecrets(t *testing.T) { workflow, err := model.ReadWorkflow(strings.NewReader(` jobs: a: name: deploy ${{ secrets.A }} b: name: deploy ${{ secrets.B }} `)) require.NoError(t, err) runner := &runnerImpl{config: &Config{Secrets: map[string]string{"A": "s3cr3t-a", "B": "s3cr3t-b"}, PresetGitHubContext: &model.GithubContext{}}} containerName := func(jobID string) string { rc, err := runner.newRunContext(t.Context(), &model.Run{JobID: jobID, Workflow: workflow}, nil) require.NoError(t, err) assert.NotContains(t, rc.Name, "s3cr3t") return rc.jobContainerName() } a, b := containerName("a"), containerName("b") assert.NotContains(t, a, "s3cr3t") assert.NotEqual(t, a, b) } func TestNewExpressionEvaluatorReadsJobOutputsWhileCombinationsWriteThem(t *testing.T) { rc := createRunContext(t) rc.caller = &caller{runContext: createRunContext(t)} rc.ExprEval = rc.NewExpressionEvaluator(t.Context()) job := rc.Run.Job() var writes sync.WaitGroup writes.Go(func() { for range 100 { unlock := lockJob(job) job.Outputs = map[string]string{"out": "value"} unlock() } }) for range 100 { rc.NewExpressionEvaluator(t.Context()) } writes.Wait() }