// 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"
	"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})
			user2APICtx := NewAPITestContext(t, baseRepo.OwnerName, baseRepo.Name, auth_model.AccessTokenScopeWriteRepository)
			defer doAPIDeleteRepository(user2APICtx)(t)

			// 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)
			defer doAPIDeleteRepository(user4APICtx)(t)

			// 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("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
}
