package server

import (
	"bytes"
	"crypto"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"encoding/base64"
	"encoding/binary"
	"encoding/json"
	"encoding/xml"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/config"
	"github.com/tidbcloud/AgentTeam/internal/store/automation"
)

const actionsRunnerHost = "meshx-actions.test"

func TestActionsRunnerRegistrationOAuthAndEmptyPoll(t *testing.T) {
	srv := newAutomationRunnerTestServer(t, "name: Empty poll\non:\n  manual: {}\njobs:\n  build:\n    steps:\n      - run: echo build\n", "1010101010101010101010101010101010101010")
	humanAuth := "Bearer " + jwt(map[string]any{"sub": "actions-runner-user"})
	workspaceID := "ws-actions-empty-poll"
	registrationToken := registerAutomationRunnerForActions(t, srv, humanAuth, workspaceID, "actions-runner-1", []string{"ubuntu-latest"})

	key := actionsRunnerKey(t)
	config := configureActionsRunner(t, srv, registrationToken, "actions-runner-1", key)
	require.NotZero(t, config.RunnerID)
	require.NotEmpty(t, config.ClientID)

	firstAccessToken := exchangeActionsRunnerOAuth(t, srv, config.ClientID, key)
	require.NotEmpty(t, firstAccessToken)
	accessToken := exchangeActionsRunnerOAuth(t, srv, config.ClientID, key)
	require.NotEqual(t, firstAccessToken, accessToken)
	firstSession := createActionsBrokerSession(t, srv, firstAccessToken)
	session := createActionsBrokerSession(t, srv, accessToken)
	require.NotEmpty(t, session.SessionID)
	require.Equal(t, firstSession.SessionID, session.SessionID)

	started := time.Now()
	message := pollActionsBrokerMessage(t, srv, accessToken)
	require.Nil(t, message)
	require.GreaterOrEqual(t, time.Since(started), 800*time.Millisecond)
}

func TestActionsRunnerConfiguredBaseURLIgnoresForwardedHeaders(t *testing.T) {
	const publicBaseURL = "https://runner.example.test/meshx"
	automationYAML := "name: Configured runner base URL\non:\n  manual: {}\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo configured\n"
	srv := newAutomationRunnerTestServerWithAutomationConfig(t, automationYAML, "1313131313131313131313131313131313131313", config.AutomationConfig{ActionsRunnerBaseURL: publicBaseURL})
	humanAuth := "Bearer " + jwt(map[string]any{"sub": "actions-runner-base-url-user"})
	workspaceID := "ws-actions-base-url"
	startAutomationRunnerTestRun(t, srv, humanAuth, workspaceID, ".agentteam/runner.yml")
	registrationToken := registerAutomationRunnerForActions(t, srv, humanAuth, workspaceID, "actions-base-url-runner", []string{"ubuntu-latest"})
	key := actionsRunnerKey(t)
	runnerConfig := configureActionsRunnerWithBaseURL(t, srv, registrationToken, "actions-base-url-runner", key, publicBaseURL)

	accessToken := exchangeActionsRunnerOAuthForAudience(t, srv, runnerConfig.ClientID, key, publicBaseURL+"/_oauth/token")
	message := pollActionsBrokerMessage(t, srv, accessToken)
	require.NotNil(t, message)
	var ref struct {
		RunnerRequestID string `json:"runner_request_id"`
		RunServiceURL   string `json:"run_service_url"`
	}
	require.NoError(t, json.Unmarshal([]byte(message.Body), &ref))
	require.NotEmpty(t, ref.RunnerRequestID)
	require.Equal(t, publicBaseURL+"/_runs/", ref.RunServiceURL)

	jobMessage := acquireActionsJob(t, srv, accessToken, ref.RunnerRequestID)
	requireActionsGitHubContext(t, jobMessage.ContextData, "pingcap/agentteam", "refs/heads/main", "1313131313131313131313131313131313131313", "Configured runner base URL", publicBaseURL)
}

func TestActionsRunnerRegistrationTokenConsumedAfterRegister(t *testing.T) {
	srv := newAutomationRunnerTestServer(t, "name: Token consumed\non:\n  manual: {}\njobs:\n  build:\n    steps:\n      - run: echo build\n", "1111111111111111111111111111111111111111")
	humanAuth := "Bearer " + jwt(map[string]any{"sub": "actions-runner-token-user"})
	workspaceID := "ws-actions-token-consumed"
	registrationToken := registerAutomationRunnerForActions(t, srv, humanAuth, workspaceID, "actions-runner-token", []string{"ubuntu-latest"})

	config := configureActionsRunner(t, srv, registrationToken, "actions-runner-token", actionsRunnerKey(t))
	require.NotEmpty(t, config.ClientID)

	registerBody, err := json.Marshal(map[string]any{
		"url":        "http://" + actionsRunnerHost + "/pingcap/agentteam",
		"group_id":   1,
		"name":       "actions-runner-token",
		"version":    "2.334.0",
		"labels":     []map[string]any{{"name": "self-hosted", "type": "system"}},
		"public_key": actionsRunnerPublicKeyXML(t, actionsRunnerKey(t)),
	})
	require.NoError(t, err)
	registerReq := newActionsRequest(http.MethodPost, "/api/v3/actions/runners/register", bytes.NewBuffer(registerBody))
	registerReq.Header.Set("Authorization", "RemoteAuth "+registrationToken)
	registerRec := httptest.NewRecorder()
	srv.handler.ServeHTTP(registerRec, registerReq)
	require.Equal(t, http.StatusUnauthorized, registerRec.Code, registerRec.Body.String())
}

func TestActionsRunnerBrokerCloseRevokesSession(t *testing.T) {
	srv := newAutomationRunnerTestServer(t, "name: Broker close\non:\n  manual: {}\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo build\n", "1212121212121212121212121212121212121212")
	humanAuth := "Bearer " + jwt(map[string]any{"sub": "actions-runner-close-user"})
	workspaceID := "ws-actions-broker-close"
	startAutomationRunnerTestRun(t, srv, humanAuth, workspaceID, ".agentteam/runner.yml")
	accessToken := registerAndAuthorizeActionsRunner(t, srv, humanAuth, workspaceID, "actions-close-runner", []string{"ubuntu-latest"})
	session := createActionsBrokerSession(t, srv, accessToken)
	require.NotEmpty(t, session.SessionID)

	message := pollActionsBrokerMessage(t, srv, accessToken)
	require.NotNil(t, message)
	var ref struct {
		RunnerRequestID string `json:"runner_request_id"`
	}
	require.NoError(t, json.Unmarshal([]byte(message.Body), &ref))
	jobMessage := acquireActionsJob(t, srv, accessToken, ref.RunnerRequestID)
	runServiceToken := actionsJobAccessToken(t, jobMessage)

	deleteReq := newActionsRequest(http.MethodDelete, "/_broker/session", nil)
	deleteReq.Header.Set("Authorization", "Bearer "+accessToken)
	deleteRec := httptest.NewRecorder()
	srv.handler.ServeHTTP(deleteRec, deleteReq)
	require.Equal(t, http.StatusNoContent, deleteRec.Code, deleteRec.Body.String())

	pollReq := newActionsRequest(http.MethodGet, "/_broker/message?sessionId="+url.QueryEscape(session.SessionID), nil)
	pollReq.Header.Set("Authorization", "Bearer "+accessToken)
	pollRec := httptest.NewRecorder()
	srv.handler.ServeHTTP(pollRec, pollReq)
	require.Equal(t, http.StatusUnauthorized, pollRec.Code, pollRec.Body.String())

	acquireReq := newActionsRequest(http.MethodPost, "/_runs/acquirejob", bytes.NewBufferString(`{"jobMessageId":"1"}`))
	acquireReq.Header.Set("Authorization", "Bearer "+accessToken)
	acquireRec := httptest.NewRecorder()
	srv.handler.ServeHTTP(acquireRec, acquireReq)
	require.Equal(t, http.StatusUnauthorized, acquireRec.Code, acquireRec.Body.String())

	renewBody, err := json.Marshal(map[string]string{"planId": jobMessage.Plan.PlanID, "jobId": jobMessage.JobID})
	require.NoError(t, err)
	requireActionsBearerStatus(t, srv, http.MethodPost, "/_runs/renewjob", runServiceToken, bytes.NewBuffer(renewBody), http.StatusUnauthorized)
}

func TestActionsRunnerRunServiceErrorsUseOfficialShape(t *testing.T) {
	srv := newAutomationRunnerTestServer(t, "name: Run service errors\non:\n  manual: {}\njobs:\n  build:\n    steps:\n      - run: echo build\n", "1414141414141414141414141414141414141414")
	humanAuth := "Bearer " + jwt(map[string]any{"sub": "actions-runner-errors-user"})
	workspaceID := "ws-actions-run-service-errors"
	accessToken := registerAndAuthorizeActionsRunner(t, srv, humanAuth, workspaceID, "actions-errors-runner", []string{"ubuntu-latest"})

	tests := []struct {
		name         string
		jobMessageID string
		status       int
	}{
		{name: "invalid job message id", jobMessageID: "not-a-number", status: http.StatusUnprocessableEntity},
		{name: "missing leased job", jobMessageID: "99999999", status: http.StatusNotFound},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			acquireActionsJobError(t, srv, accessToken, tt.jobMessageID, tt.status)
		})
	}
}

func TestActionsRunnerShellJobSuccess(t *testing.T) {
	automationYAML := "name: Actions runner success\non:\n  manual: {}\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - id: hello\n        run: echo hello\n"
	srv := newAutomationRunnerTestServer(t, automationYAML, "2020202020202020202020202020202020202020")
	humanAuth := "Bearer " + jwt(map[string]any{"sub": "actions-runner-success-user"})
	workspaceID := "ws-actions-success"
	run := startAutomationRunnerTestRun(t, srv, humanAuth, workspaceID, ".agentteam/runner.yml")
	accessToken := registerAndAuthorizeActionsRunner(t, srv, humanAuth, workspaceID, "actions-success-runner", []string{"ubuntu-latest"})

	message := pollActionsBrokerMessage(t, srv, accessToken)
	require.NotNil(t, message)
	require.Equal(t, "RunnerJobRequest", message.MessageType)
	var ref struct {
		RunnerRequestID string `json:"runner_request_id"`
		RunServiceURL   string `json:"run_service_url"`
		BillingOwnerID  string `json:"billing_owner_id"`
	}
	require.NoError(t, json.Unmarshal([]byte(message.Body), &ref))
	require.NotEmpty(t, ref.RunnerRequestID)
	require.Equal(t, "http://"+actionsRunnerHost+"/_runs/", ref.RunServiceURL)

	jobMessage := acquireActionsJob(t, srv, accessToken, ref.RunnerRequestID)
	require.Equal(t, "RunnerJobRequest", jobMessage.MessageType)
	require.Equal(t, "build", jobMessage.JobName)
	require.Len(t, jobMessage.Steps, 1)
	require.Equal(t, float64(4), jobMessage.Steps[0]["type"])
	require.Equal(t, "success()", jobMessage.Steps[0]["condition"])
	require.Equal(t, float64(30), jobMessage.Steps[0]["timeoutInMinutes"])
	requireActionsGitHubContext(t, jobMessage.ContextData, "pingcap/agentteam", "refs/heads/main", "2020202020202020202020202020202020202020", "Actions runner success", "http://"+actionsRunnerHost)
	runServiceToken := actionsJobAccessToken(t, jobMessage)
	require.NotEqual(t, accessToken, runServiceToken)
	runServiceClaims := decodeActionsAccessTokenClaims(t, runServiceToken)
	require.Equal(t, "run_service", runServiceClaims.Scope)
	require.Greater(t, runServiceClaims.ExpiresAt-runServiceClaims.IssuedAt, int64((24*time.Hour)/time.Second))
	requireActionsBearerStatus(t, srv, http.MethodGet, "/_broker/message?sessionId=test", runServiceToken, nil, http.StatusUnauthorized)
	renewBody, err := json.Marshal(map[string]string{"planId": jobMessage.Plan.PlanID, "jobId": jobMessage.JobID})
	require.NoError(t, err)
	requireActionsBearerStatus(t, srv, http.MethodPost, "/_runs/renewjob", accessToken, bytes.NewBuffer(renewBody), http.StatusUnauthorized)

	renewActionsJob(t, srv, runServiceToken, jobMessage.Plan.PlanID, jobMessage.JobID)
	completeActionsJob(t, srv, runServiceToken, jobMessage.Plan.PlanID, jobMessage.JobID, 0)

	var completedJobRun automation.AutomationJobRun
	require.NoError(t, srv.meshxDB.Where("workspace_id = ? AND run_id = ? AND job_id = ?", workspaceID, run.ID, "build").First(&completedJobRun).Error)
	require.Contains(t, completedJobRun.OutputJSON, `"step_results"`)
	require.Contains(t, completedJobRun.OutputJSON, `"annotations"`)
	require.Contains(t, completedJobRun.OutputJSON, `"telemetry"`)
	require.Contains(t, completedJobRun.OutputJSON, `"completedLogLines":1`)

	apiRun := getActionsAutomationRun(t, srv, humanAuth, workspaceID, run.ID)
	require.Equal(t, "succeeded", apiRun.Status)

	jobRuns := listActionsAutomationJobRuns(t, srv, humanAuth, workspaceID, run.ID)
	require.Len(t, jobRuns, 1)
	require.Equal(t, "build", jobRuns[0].JobID)
	require.Equal(t, "succeeded", jobRuns[0].Status)
	require.Contains(t, jobRuns[0].OutputJSON, `"outputs"`)
	require.Contains(t, jobRuns[0].OutputJSON, `"step_results"`)
	require.Contains(t, jobRuns[0].OutputJSON, `"annotations"`)
	require.Contains(t, jobRuns[0].OutputJSON, `"telemetry"`)
}

func TestActionsRunnerOAuthRefreshKeepsRunServiceTokenValid(t *testing.T) {
	automationYAML := "name: Actions runner refresh\non:\n  manual: {}\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - id: refresh\n        run: echo refresh\n"
	srv := newAutomationRunnerTestServer(t, automationYAML, "2222222222222222222222222222222222222222")
	humanAuth := "Bearer " + jwt(map[string]any{"sub": "actions-runner-refresh-user"})
	workspaceID := "ws-actions-refresh"
	run := startAutomationRunnerTestRun(t, srv, humanAuth, workspaceID, ".agentteam/runner.yml")
	registrationToken := registerAutomationRunnerForActions(t, srv, humanAuth, workspaceID, "actions-refresh-runner", []string{"ubuntu-latest"})
	key := actionsRunnerKey(t)
	config := configureActionsRunner(t, srv, registrationToken, "actions-refresh-runner", key)
	accessToken := exchangeActionsRunnerOAuth(t, srv, config.ClientID, key)

	message := pollActionsBrokerMessage(t, srv, accessToken)
	require.NotNil(t, message)
	var ref struct {
		RunnerRequestID string `json:"runner_request_id"`
	}
	require.NoError(t, json.Unmarshal([]byte(message.Body), &ref))
	jobMessage := acquireActionsJob(t, srv, accessToken, ref.RunnerRequestID)
	runServiceToken := actionsJobAccessToken(t, jobMessage)

	refreshedAccessToken := exchangeActionsRunnerOAuth(t, srv, config.ClientID, key)
	require.NotEqual(t, accessToken, refreshedAccessToken)
	renewActionsJob(t, srv, runServiceToken, jobMessage.Plan.PlanID, jobMessage.JobID)
	completeActionsJob(t, srv, runServiceToken, jobMessage.Plan.PlanID, jobMessage.JobID, 0)

	var completedJobRun automation.AutomationJobRun
	require.NoError(t, srv.meshxDB.Where("workspace_id = ? AND run_id = ? AND job_id = ?", workspaceID, run.ID, "build").First(&completedJobRun).Error)
	require.Equal(t, automation.AutomationJobRunStatusSucceeded, completedJobRun.Status)
}

func TestActionsRunnerCompleteJobAcceptsLargeMetadataPayload(t *testing.T) {
	automationYAML := "name: Actions runner large metadata\non:\n  manual: {}\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - id: hello\n        run: echo hello\n"
	srv := newAutomationRunnerTestServer(t, automationYAML, "2121212121212121212121212121212121212121")
	humanAuth := "Bearer " + jwt(map[string]any{"sub": "actions-runner-large-metadata-user"})
	workspaceID := "ws-actions-large-metadata"
	run := startAutomationRunnerTestRun(t, srv, humanAuth, workspaceID, ".agentteam/runner.yml")
	accessToken := registerAndAuthorizeActionsRunner(t, srv, humanAuth, workspaceID, "actions-large-metadata-runner", []string{"ubuntu-latest"})

	message := pollActionsBrokerMessage(t, srv, accessToken)
	require.NotNil(t, message)
	var ref struct {
		RunnerRequestID string `json:"runner_request_id"`
	}
	require.NoError(t, json.Unmarshal([]byte(message.Body), &ref))
	jobMessage := acquireActionsJob(t, srv, accessToken, ref.RunnerRequestID)
	runServiceToken := actionsJobAccessToken(t, jobMessage)

	largeMessage := strings.Repeat("x", 90*1024)
	body, err := json.Marshal(map[string]any{
		"planId":      jobMessage.Plan.PlanID,
		"jobId":       jobMessage.JobID,
		"conclusion":  0,
		"annotations": []map[string]any{{"message": largeMessage, "annotationLevel": "warning"}},
	})
	require.NoError(t, err)
	require.Greater(t, len(body), 32*1024)
	require.Greater(t, len(body), 64*1024)
	require.Less(t, len(body), 192*1024)
	req := newActionsRequest(http.MethodPost, "/_runs/completejob", bytes.NewBuffer(body))
	req.Header.Set("Authorization", "Bearer "+runServiceToken)
	rec := httptest.NewRecorder()
	srv.handler.ServeHTTP(rec, req)
	require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())

	var completedJobRun automation.AutomationJobRun
	require.NoError(t, srv.meshxDB.Where("workspace_id = ? AND run_id = ? AND job_id = ?", workspaceID, run.ID, "build").First(&completedJobRun).Error)
	require.Greater(t, len(completedJobRun.OutputJSON), 64*1024)
	require.Contains(t, completedJobRun.OutputJSON, `"annotations"`)
	require.Contains(t, completedJobRun.OutputJSON, strings.Repeat("x", 128))
}

func TestActionsRunnerFailureConclusionFailsRun(t *testing.T) {
	automationYAML := "name: Actions runner failure\non:\n  manual: {}\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - id: fail\n        run: exit 1\n"
	srv := newAutomationRunnerTestServer(t, automationYAML, "3030303030303030303030303030303030303030")
	humanAuth := "Bearer " + jwt(map[string]any{"sub": "actions-runner-failure-user"})
	workspaceID := "ws-actions-failure"
	run := startAutomationRunnerTestRun(t, srv, humanAuth, workspaceID, ".agentteam/runner.yml")
	accessToken := registerAndAuthorizeActionsRunner(t, srv, humanAuth, workspaceID, "actions-failure-runner", []string{"ubuntu-latest"})

	message := pollActionsBrokerMessage(t, srv, accessToken)
	require.NotNil(t, message)
	var ref struct {
		RunnerRequestID string `json:"runner_request_id"`
	}
	require.NoError(t, json.Unmarshal([]byte(message.Body), &ref))
	jobMessage := acquireActionsJob(t, srv, accessToken, ref.RunnerRequestID)
	completeActionsJob(t, srv, actionsJobAccessToken(t, jobMessage), jobMessage.Plan.PlanID, jobMessage.JobID, 2)

	var failedJobRun automation.AutomationJobRun
	require.NoError(t, srv.meshxDB.Where("workspace_id = ? AND run_id = ? AND job_id = ?", workspaceID, run.ID, "build").First(&failedJobRun).Error)
	require.Equal(t, automation.AutomationJobRunStatusFailed, failedJobRun.Status)
	require.Equal(t, 3, failedJobRun.MaxAttempts)
	require.Contains(t, failedJobRun.OutputJSON, `"step_results"`)
	require.Contains(t, failedJobRun.OutputJSON, `"annotations"`)
	require.Contains(t, failedJobRun.OutputJSON, `"completedLogLines":1`)
	var failedJob automation.AutomationJob
	require.NoError(t, srv.meshxDB.Where("workspace_id = ? AND run_id = ? AND job_run_id = ?", workspaceID, run.ID, failedJobRun.ID).First(&failedJob).Error)
	require.Equal(t, automation.AutomationJobStatusFailed, failedJob.Status)

	apiRun := getActionsAutomationRun(t, srv, humanAuth, workspaceID, run.ID)
	require.Equal(t, "failed", apiRun.Status)
	require.Contains(t, apiRun.ErrorJSON, "actions runner concluded")
}

func TestActionsRunnerUnsupportedAutomationsRejectedBeforeDispatch(t *testing.T) {
	tests := []struct {
		name           string
		automationYAML string
		commitSHA      string
	}{
		{
			name:           "external uses",
			automationYAML: "name: Unsupported uses\non:\n  manual: {}\njobs:\n  build:\n    steps:\n      - uses: actions/checkout@v4\n",
			commitSHA:      "4040404040404040404040404040404040404040",
		},
		{
			name:           "env and defaults",
			automationYAML: "name: Unsupported env defaults\non:\n  manual: {}\nenv:\n  GLOBAL: true\ndefaults:\n  run:\n    shell: bash\njobs:\n  build:\n    env:\n      JOB: true\n    defaults:\n      run:\n        working-directory: src\n    steps:\n      - run: echo build\n        env:\n          STEP: true\n",
			commitSHA:      "4444444444444444444444444444444444444444",
		},
		{
			name:           "run expression",
			automationYAML: "name: Unsupported run expression\non:\n  manual: {}\njobs:\n  build:\n    steps:\n      - run: echo ${{ github.ref }}\n",
			commitSHA:      "4646464646464646464646464646464646464646",
		},
		{
			name:           "malformed agentteam wait",
			automationYAML: "name: Malformed wait\non:\n  manual: {}\njobs:\n  approve:\n    if: ${{ inputs.ready }}\n    steps:\n      - uses: agentteam/wait-event\n        with:\n          event_type: automation_event_received\n          resource_type: event\n          resource_id: approve-deploy\n      - run: echo deploy\n",
			commitSHA:      "4545454545454545454545454545454545454545",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			srv := newAutomationRunnerTestServer(t, tt.automationYAML, tt.commitSHA)
			humanAuth := "Bearer " + jwt(map[string]any{"sub": "actions-runner-" + strings.ReplaceAll(tt.name, " ", "-") + "-user"})
			workspaceID := "ws-actions-" + strings.ReplaceAll(tt.name, " ", "-")

			body, err := json.Marshal(map[string]string{"ref": "refs/heads/main", "definitionPath": ".agentteam/runner.yml"})
			require.NoError(t, err)
			startReq := httptest.NewRequest(http.MethodPost, "/api/v1/automations/repositories/pingcap/agentteam/runs", bytes.NewBuffer(body))
			setAutomationRunnerHeaders(startReq, humanAuth, workspaceID)
			startRec := httptest.NewRecorder()
			srv.handler.ServeHTTP(startRec, startReq)
			require.Equal(t, http.StatusBadRequest, startRec.Code, startRec.Body.String())
			require.Contains(t, startRec.Body.String(), "Automation spec is invalid")

			var jobs int64
			require.NoError(t, srv.meshxDB.Model(&automation.AutomationJob{}).Where("workspace_id = ?", workspaceID).Count(&jobs).Error)
			require.Zero(t, jobs)
			var waits int64
			require.NoError(t, srv.meshxDB.Model(&automation.AutomationWait{}).Where("workspace_id = ?", workspaceID).Count(&waits).Error)
			require.Zero(t, waits)
		})
	}
}

func TestActionsRunnerPayloadFailureDiagnosticsVisible(t *testing.T) {
	const wantErrorText = "actions/checkout@v4"
	automationYAML := "name: Payload diagnostics\non:\n  manual: {}\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo valid-before-corruption\n"
	srv := newAutomationRunnerTestServer(t, automationYAML, "4747474747474747474747474747474747474747")
	humanAuth := "Bearer " + jwt(map[string]any{"sub": "actions-runner-payload-user"})
	workspaceID := "ws-actions-payload"
	run := startAutomationRunnerTestRun(t, srv, humanAuth, workspaceID, ".agentteam/runner.yml")

	inputJSON, err := json.Marshal(map[string]any{"steps": []map[string]any{{"uses": wantErrorText}}})
	require.NoError(t, err)
	require.NoError(t, srv.meshxDB.Model(&automation.AutomationJobRun{}).
		Where("workspace_id = ? AND run_id = ? AND job_id = ?", workspaceID, run.ID, "build").
		Update("input_json", string(inputJSON)).Error)

	accessToken := registerAndAuthorizeActionsRunner(t, srv, humanAuth, workspaceID, "actions-payload-runner", []string{"ubuntu-latest"})
	message := pollActionsBrokerMessage(t, srv, accessToken)
	require.NotNil(t, message)
	var ref struct {
		RunnerRequestID string `json:"runner_request_id"`
	}
	require.NoError(t, json.Unmarshal([]byte(message.Body), &ref))

	acquireActionsJobError(t, srv, accessToken, ref.RunnerRequestID, http.StatusUnprocessableEntity)

	apiRun := getActionsAutomationRun(t, srv, humanAuth, workspaceID, run.ID)
	require.Equal(t, "failed", apiRun.Status)
	require.Contains(t, apiRun.ErrorJSON, wantErrorText)

	jobRuns := listActionsAutomationJobRuns(t, srv, humanAuth, workspaceID, run.ID)
	require.Len(t, jobRuns, 1)
	require.Equal(t, "build", jobRuns[0].JobID)
	require.Equal(t, "failed", jobRuns[0].Status)
	require.Contains(t, jobRuns[0].ErrorJSON, wantErrorText)
}

func TestActionsRunnerAgentTeamWaitDoesNotOccupyRunnerLease(t *testing.T) {
	automationYAML := "name: Wait then deploy\non:\n  manual: {}\njobs:\n  approve:\n    steps:\n      - uses: agentteam/agent-task\n        with:\n          task_id: tsk_approve_deploy\n  deploy:\n    needs: approve\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo deploy\n"
	srv := newAutomationRunnerTestServer(t, automationYAML, "5050505050505050505050505050505050505050")
	humanAuth := "Bearer " + jwt(map[string]any{"sub": "actions-runner-wait-user"})
	workspaceID := "ws-actions-wait"
	run := startAutomationRunnerTestRun(t, srv, humanAuth, workspaceID, ".agentteam/runner.yml")

	var queuedJobs int64
	require.NoError(t, srv.meshxDB.Model(&automation.AutomationJob{}).Where("workspace_id = ? AND run_id = ?", workspaceID, run.ID).Count(&queuedJobs).Error)
	require.Zero(t, queuedJobs)

	accessToken := registerAndAuthorizeActionsRunner(t, srv, humanAuth, workspaceID, "actions-wait-runner", []string{"ubuntu-latest"})
	waitsReq := httptest.NewRequest(http.MethodGet, "/api/v1/automations/runs/"+run.ID+"/waits", nil)
	setAutomationRunnerHeaders(waitsReq, humanAuth, workspaceID)
	waitsRec := httptest.NewRecorder()
	srv.handler.ServeHTTP(waitsRec, waitsReq)
	require.Equal(t, http.StatusOK, waitsRec.Code, waitsRec.Body.String())
	var waits []struct {
		ID     string `json:"id"`
		Status string `json:"status"`
	}
	require.NoError(t, json.Unmarshal(waitsRec.Body.Bytes(), &waits))
	require.Len(t, waits, 1)
	require.Equal(t, "active", waits[0].Status)

	completeReq := httptest.NewRequest(http.MethodPost, "/api/v1/automations/runs/"+run.ID+"/waits/"+waits[0].ID+"/complete-task", bytes.NewBufferString(`{"taskId":"tsk_approve_deploy","status":"done"}`))
	setAutomationRunnerHeaders(completeReq, humanAuth, workspaceID)
	completeRec := httptest.NewRecorder()
	srv.handler.ServeHTTP(completeRec, completeReq)
	require.Equal(t, http.StatusOK, completeRec.Code, completeRec.Body.String())

	message := pollActionsBrokerMessage(t, srv, accessToken)
	require.NotNil(t, message)
	var ref struct {
		RunnerRequestID string `json:"runner_request_id"`
	}
	require.NoError(t, json.Unmarshal([]byte(message.Body), &ref))
	jobMessage := acquireActionsJob(t, srv, accessToken, ref.RunnerRequestID)
	require.Equal(t, "deploy", jobMessage.JobName)
}

type actionsRunnerConfig struct {
	RunnerID int64
	ClientID string
}

type actionsBrokerSession struct {
	SessionID string `json:"sessionId"`
	OwnerName string `json:"ownerName"`
}

type actionsTaskAgentMessage struct {
	MessageID   int64  `json:"messageId"`
	MessageType string `json:"messageType"`
	Body        string `json:"body"`
}

type actionsAgentJobMessage struct {
	MessageType string                    `json:"messageType"`
	Plan        actionsAgentJobPlan       `json:"plan"`
	JobID       string                    `json:"jobId"`
	JobName     string                    `json:"jobName"`
	RequestID   int64                     `json:"requestId"`
	Resources   actionsAgentJobResources  `json:"resources"`
	Steps       []map[string]any          `json:"steps"`
	ContextData map[string]any            `json:"contextData"`
	Variables   map[string]map[string]any `json:"variables"`
}

type actionsAgentJobPlan struct {
	PlanID string `json:"planId"`
}

type actionsAgentJobResources struct {
	Endpoints []actionsAgentJobEndpoint `json:"endpoints"`
}

type actionsAgentJobEndpoint struct {
	Name          string                       `json:"name"`
	Authorization actionsAgentJobAuthorization `json:"authorization"`
}

type actionsAgentJobAuthorization struct {
	Parameters map[string]string `json:"Parameters"`
}

type actionsAccessTokenClaims struct {
	Scope     string `json:"scope"`
	IssuedAt  int64  `json:"iat"`
	ExpiresAt int64  `json:"exp"`
}

type actionsAutomationRun struct {
	Status    string `json:"status"`
	ErrorJSON string `json:"errorJson"`
}

type actionsAutomationJobRun struct {
	JobID      string `json:"jobId"`
	Status     string `json:"status"`
	OutputJSON string `json:"outputJson"`
	ErrorJSON  string `json:"errorJson"`
}

func registerAndAuthorizeActionsRunner(t *testing.T, srv *Server, humanAuth string, workspaceID string, runnerID string, labels []string) string {
	t.Helper()
	registrationToken := registerAutomationRunnerForActions(t, srv, humanAuth, workspaceID, runnerID, labels)
	key := actionsRunnerKey(t)
	config := configureActionsRunner(t, srv, registrationToken, runnerID, key)
	return exchangeActionsRunnerOAuth(t, srv, config.ClientID, key)
}

func registerAutomationRunnerForActions(t *testing.T, srv *Server, humanAuth string, workspaceID string, runnerID string, labels []string) string {
	t.Helper()
	registerBody, err := json.Marshal(map[string]any{"runnerId": runnerID, "name": runnerID, "labels": labels})
	require.NoError(t, err)
	registerReq := httptest.NewRequest(http.MethodPost, "/api/v1/automations/runners/register", bytes.NewBuffer(registerBody))
	setAutomationRunnerHeaders(registerReq, humanAuth, workspaceID)
	registerRec := httptest.NewRecorder()
	srv.handler.ServeHTTP(registerRec, registerReq)
	require.Equal(t, http.StatusCreated, registerRec.Code, registerRec.Body.String())
	var registration automationRunnerRegistration
	require.NoError(t, json.Unmarshal(registerRec.Body.Bytes(), &registration))
	require.Equal(t, runnerID, registration.RunnerID)
	require.NotEmpty(t, registration.RegistrationToken)
	return registration.RegistrationToken
}

func configureActionsRunner(t *testing.T, srv *Server, registrationToken string, runnerName string, key *rsa.PrivateKey) actionsRunnerConfig {
	t.Helper()
	return configureActionsRunnerWithBaseURL(t, srv, registrationToken, runnerName, key, "http://"+actionsRunnerHost)
}

func configureActionsRunnerWithBaseURL(t *testing.T, srv *Server, registrationToken string, runnerName string, key *rsa.PrivateKey, baseURL string) actionsRunnerConfig {
	t.Helper()
	bootstrapReq := newActionsRequest(http.MethodPost, "/api/v3/actions/runner-registration", bytes.NewBufferString(`{"url":"http://`+actionsRunnerHost+`/pingcap/agentteam","runner_event":"register"}`))
	bootstrapReq.Header.Set("Authorization", "RemoteAuth "+registrationToken)
	bootstrapRec := httptest.NewRecorder()
	srv.handler.ServeHTTP(bootstrapRec, bootstrapReq)
	require.Equal(t, http.StatusOK, bootstrapRec.Code, bootstrapRec.Body.String())
	require.Contains(t, bootstrapRec.Body.String(), `"use_v2_flow":true`)
	var bootstrap struct {
		URL string `json:"url"`
	}
	require.NoError(t, json.Unmarshal(bootstrapRec.Body.Bytes(), &bootstrap))
	require.Equal(t, baseURL+"/_broker/", bootstrap.URL)

	groupsReq := newActionsRequest(http.MethodGet, "/api/v3/repos/pingcap/agentteam/actions/runner-groups", nil)
	groupsReq.Header.Set("Authorization", "RemoteAuth "+registrationToken)
	groupsRec := httptest.NewRecorder()
	srv.handler.ServeHTTP(groupsRec, groupsReq)
	require.Equal(t, http.StatusOK, groupsRec.Code, groupsRec.Body.String())
	require.Contains(t, groupsRec.Body.String(), `"name":"Default"`)

	runnersReq := newActionsRequest(http.MethodGet, "/api/v3/repos/pingcap/agentteam/actions/runners?name="+url.QueryEscape(runnerName), nil)
	runnersReq.Header.Set("Authorization", "RemoteAuth "+registrationToken)
	runnersRec := httptest.NewRecorder()
	srv.handler.ServeHTTP(runnersRec, runnersReq)
	require.Equal(t, http.StatusOK, runnersRec.Code, runnersRec.Body.String())
	require.Contains(t, runnersRec.Body.String(), `"total_count":0`)

	registerBody, err := json.Marshal(map[string]any{
		"url":        "http://" + actionsRunnerHost + "/pingcap/agentteam",
		"group_id":   1,
		"name":       runnerName,
		"version":    "2.334.0",
		"labels":     []map[string]any{{"name": "self-hosted", "type": "system"}, {"name": "Linux", "type": "system"}, {"name": "X64", "type": "system"}},
		"public_key": actionsRunnerPublicKeyXML(t, key),
	})
	require.NoError(t, err)
	registerReq := newActionsRequest(http.MethodPost, "/api/v3/actions/runners/register", bytes.NewBuffer(registerBody))
	registerReq.Header.Set("Authorization", "RemoteAuth "+registrationToken)
	registerRec := httptest.NewRecorder()
	srv.handler.ServeHTTP(registerRec, registerReq)
	require.Equal(t, http.StatusOK, registerRec.Code, registerRec.Body.String())
	var response struct {
		ID            int64 `json:"id"`
		Authorization struct {
			AuthorizationURL       string `json:"authorization_url"`
			LegacyAuthorizationURL string `json:"legacy_authorization_url"`
			ClientID               string `json:"client_id"`
			ServerURL              string `json:"server_url"`
		} `json:"authorization"`
	}
	require.NoError(t, json.Unmarshal(registerRec.Body.Bytes(), &response))
	require.NotZero(t, response.ID)
	require.NotEmpty(t, response.Authorization.ClientID)
	require.Equal(t, baseURL+"/_broker/", response.Authorization.ServerURL)
	require.Equal(t, baseURL+"/_oauth/token", response.Authorization.AuthorizationURL)
	require.Equal(t, baseURL+"/_oauth/token", response.Authorization.LegacyAuthorizationURL)
	return actionsRunnerConfig{RunnerID: response.ID, ClientID: response.Authorization.ClientID}
}

func exchangeActionsRunnerOAuth(t *testing.T, srv *Server, clientID string, key *rsa.PrivateKey) string {
	t.Helper()
	return exchangeActionsRunnerOAuthForAudience(t, srv, clientID, key, "http://"+actionsRunnerHost+"/_oauth/token")
}

func exchangeActionsRunnerOAuthForAudience(t *testing.T, srv *Server, clientID string, key *rsa.PrivateKey, audience string) string {
	t.Helper()
	form := url.Values{}
	form.Set("grant_type", "client_credentials")
	form.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
	form.Set("client_assertion", signActionsRunnerAssertion(t, clientID, audience, key))
	req := newActionsRequest(http.MethodPost, "/_oauth/token", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	srv.handler.ServeHTTP(rec, req)
	require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
	var response struct {
		AccessToken string `json:"access_token"`
		TokenType   string `json:"token_type"`
	}
	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response))
	require.Equal(t, "Bearer", response.TokenType)
	require.NotEmpty(t, response.AccessToken)
	require.Len(t, strings.Split(response.AccessToken, "."), 3)
	require.NotContains(t, response.AccessToken, "mxar_")
	requireActionsAccessTokenDoesNotExposeSessionToken(t, response.AccessToken)
	return response.AccessToken
}

func createActionsBrokerSession(t *testing.T, srv *Server, accessToken string) actionsBrokerSession {
	t.Helper()
	req := newActionsRequest(http.MethodPost, "/_broker/session", bytes.NewBufferString(`{"sessionId":"00000000-0000-0000-0000-000000000000","ownerName":"test-runner"}`))
	req.Header.Set("Authorization", "Bearer "+accessToken)
	rec := httptest.NewRecorder()
	srv.handler.ServeHTTP(rec, req)
	require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
	var response actionsBrokerSession
	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response))
	return response
}

func pollActionsBrokerMessage(t *testing.T, srv *Server, accessToken string) *actionsTaskAgentMessage {
	t.Helper()
	req := newActionsRequest(http.MethodGet, "/_broker/message?sessionId=test&status=Online&runnerVersion=2.334.0&os=Linux&architecture=X64&disableUpdate=false", nil)
	req.Header.Set("Authorization", "Bearer "+accessToken)
	rec := httptest.NewRecorder()
	srv.handler.ServeHTTP(rec, req)
	require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
	if strings.TrimSpace(rec.Body.String()) == "null" {
		return nil
	}
	var message actionsTaskAgentMessage
	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &message))
	return &message
}

func acquireActionsJob(t *testing.T, srv *Server, accessToken string, runnerRequestID string) actionsAgentJobMessage {
	t.Helper()
	body, err := json.Marshal(map[string]any{"jobMessageId": runnerRequestID, "runnerOS": "Linux", "billingOwnerId": "meshx"})
	require.NoError(t, err)
	req := newActionsRequest(http.MethodPost, "/_runs/acquirejob", bytes.NewBuffer(body))
	req.Header.Set("Authorization", "Bearer "+accessToken)
	rec := httptest.NewRecorder()
	srv.handler.ServeHTTP(rec, req)
	require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
	var message actionsAgentJobMessage
	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &message))
	require.NotEmpty(t, message.JobID)
	require.NotEmpty(t, message.Plan.PlanID)
	return message
}

func acquireActionsJobError(t *testing.T, srv *Server, accessToken string, runnerRequestID string, status int) {
	t.Helper()
	body, err := json.Marshal(map[string]string{"jobMessageId": runnerRequestID})
	require.NoError(t, err)
	req := newActionsRequest(http.MethodPost, "/_runs/acquirejob", bytes.NewBuffer(body))
	req.Header.Set("Authorization", "Bearer "+accessToken)
	rec := httptest.NewRecorder()
	srv.handler.ServeHTTP(rec, req)
	require.Equal(t, status, rec.Code, rec.Body.String())

	var response map[string]any
	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response))
	require.Equal(t, "actions-run-service", response["source"])
	require.Equal(t, float64(status), response["statusCode"])
	require.NotEmpty(t, response["errorMessage"])
	require.NotContains(t, response, "code")
	require.NotContains(t, response, "message")
}

func getActionsAutomationRun(t *testing.T, srv *Server, auth string, workspaceID string, runID string) actionsAutomationRun {
	t.Helper()
	req := httptest.NewRequest(http.MethodGet, "/api/v1/automations/runs/"+runID, nil)
	setAutomationRunnerHeaders(req, auth, workspaceID)
	rec := httptest.NewRecorder()
	srv.handler.ServeHTTP(rec, req)
	require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
	var run actionsAutomationRun
	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &run))
	return run
}

func listActionsAutomationJobRuns(t *testing.T, srv *Server, auth string, workspaceID string, runID string) []actionsAutomationJobRun {
	t.Helper()
	req := httptest.NewRequest(http.MethodGet, "/api/v1/automations/runs/"+runID+"/jobs", nil)
	setAutomationRunnerHeaders(req, auth, workspaceID)
	rec := httptest.NewRecorder()
	srv.handler.ServeHTTP(rec, req)
	require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
	var jobRuns []actionsAutomationJobRun
	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &jobRuns))
	return jobRuns
}

func renewActionsJob(t *testing.T, srv *Server, accessToken string, planID string, jobID string) {
	t.Helper()
	body, err := json.Marshal(map[string]string{"planId": planID, "jobId": jobID})
	require.NoError(t, err)
	req := newActionsRequest(http.MethodPost, "/_runs/renewjob", bytes.NewBuffer(body))
	req.Header.Set("Authorization", "Bearer "+accessToken)
	rec := httptest.NewRecorder()
	srv.handler.ServeHTTP(rec, req)
	require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
	require.Contains(t, rec.Body.String(), "lockedUntil")
}

func completeActionsJob(t *testing.T, srv *Server, accessToken string, planID string, jobID string, conclusion int) {
	t.Helper()
	body, err := json.Marshal(map[string]any{
		"planId":     planID,
		"jobId":      jobID,
		"conclusion": conclusion,
		"outputs":    map[string]any{"ok": map[string]any{"value": "true"}},
		"stepResults": []map[string]any{{
			"name":              "hello",
			"status":            "completed",
			"conclusion":        "success",
			"completedLogLines": float64(1),
		}},
		"annotations": []map[string]any{{
			"message":         "line 1",
			"annotationLevel": "failure",
		}},
		"telemetry": []map[string]any{{
			"name":  "meshx.smoke",
			"value": 1,
		}},
	})
	require.NoError(t, err)
	req := newActionsRequest(http.MethodPost, "/_runs/completejob", bytes.NewBuffer(body))
	req.Header.Set("Authorization", "Bearer "+accessToken)
	rec := httptest.NewRecorder()
	srv.handler.ServeHTTP(rec, req)
	require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
}

func requireActionsGitHubContext(t *testing.T, contextData map[string]any, repository string, ref string, sha string, automation string, serverURL string) {
	t.Helper()
	rawGitHub, ok := contextData["github"].(map[string]any)
	require.True(t, ok, "missing github context in %#v", contextData)
	require.Equal(t, float64(2), rawGitHub["t"])
	items, ok := rawGitHub["d"].([]any)
	require.True(t, ok, "missing github context entries in %#v", rawGitHub)
	values := map[string]string{}
	for _, item := range items {
		pair, ok := item.(map[string]any)
		require.True(t, ok, "invalid github context pair %#v", item)
		key, ok := pair["k"].(string)
		require.True(t, ok, "invalid github context key %#v", pair)
		value, ok := pair["v"].(map[string]any)
		require.True(t, ok, "invalid github context value %#v", pair)
		text, ok := value["s"].(string)
		require.True(t, ok, "invalid github context string %#v", value)
		values[key] = text
	}
	require.Equal(t, repository, values["repository"])
	require.Equal(t, ref, values["ref"])
	require.Equal(t, sha, values["sha"])
	require.Equal(t, automation, values["automation"])
	require.Equal(t, "build", values["job"])
	require.Equal(t, "1", values["run_attempt"])
	require.Equal(t, serverURL, values["server_url"])
}

func actionsJobAccessToken(t *testing.T, message actionsAgentJobMessage) string {
	t.Helper()
	for _, endpoint := range message.Resources.Endpoints {
		if endpoint.Name == "SystemVssConnection" {
			token := strings.TrimSpace(endpoint.Authorization.Parameters["AccessToken"])
			require.NotEmpty(t, token)
			return token
		}
	}
	require.Fail(t, "missing SystemVssConnection endpoint")
	return ""
}

func requireActionsBearerStatus(t *testing.T, srv *Server, method string, target string, accessToken string, body io.Reader, status int) {
	t.Helper()
	req := newActionsRequest(method, target, body)
	req.Header.Set("Authorization", "Bearer "+accessToken)
	rec := httptest.NewRecorder()
	srv.handler.ServeHTTP(rec, req)
	require.Equal(t, status, rec.Code, rec.Body.String())
}

func decodeActionsAccessTokenClaims(t *testing.T, token string) actionsAccessTokenClaims {
	t.Helper()
	parts := strings.Split(strings.TrimSpace(token), ".")
	require.Len(t, parts, 3)
	payload, err := base64.RawURLEncoding.DecodeString(parts[1])
	require.NoError(t, err)
	requireActionsAccessTokenDoesNotExposeSessionToken(t, token)
	var claims actionsAccessTokenClaims
	require.NoError(t, json.Unmarshal(payload, &claims))
	require.NotEmpty(t, claims.Scope)
	require.NotZero(t, claims.IssuedAt)
	require.NotZero(t, claims.ExpiresAt)
	return claims
}

func requireActionsAccessTokenDoesNotExposeSessionToken(t *testing.T, token string) {
	t.Helper()
	parts := strings.Split(strings.TrimSpace(token), ".")
	require.Len(t, parts, 3)
	payload, err := base64.RawURLEncoding.DecodeString(parts[1])
	require.NoError(t, err)
	var rawClaims map[string]any
	require.NoError(t, json.Unmarshal(payload, &rawClaims))
	require.NotContains(t, rawClaims, "sessionToken")
}

func newActionsRequest(method string, target string, body io.Reader) *http.Request {
	req := httptest.NewRequest(method, target, body)
	req.Host = actionsRunnerHost
	// URL-bearing responses must come from configured base URLs, not forwarded headers.
	req.Header.Set("X-Forwarded-Host", "spoofed-actions.example.test")
	req.Header.Set("X-Forwarded-Proto", "https")
	return req
}

func actionsRunnerKey(t *testing.T) *rsa.PrivateKey {
	t.Helper()
	key, err := rsa.GenerateKey(rand.Reader, 2048)
	require.NoError(t, err)
	return key
}

func actionsRunnerPublicKeyXML(t *testing.T, key *rsa.PrivateKey) string {
	t.Helper()
	type rsaKeyValue struct {
		XMLName  xml.Name `xml:"RSAKeyValue"`
		Modulus  string   `xml:"Modulus"`
		Exponent string   `xml:"Exponent"`
	}
	exponent := make([]byte, 8)
	binary.BigEndian.PutUint64(exponent, uint64(key.PublicKey.E))
	exponent = bytes.TrimLeft(exponent, "\x00")
	encoded, err := xml.Marshal(rsaKeyValue{
		Modulus:  base64.StdEncoding.EncodeToString(key.PublicKey.N.Bytes()),
		Exponent: base64.StdEncoding.EncodeToString(exponent),
	})
	require.NoError(t, err)
	return string(encoded)
}

func signActionsRunnerAssertion(t *testing.T, clientID string, audience string, key *rsa.PrivateKey) string {
	t.Helper()
	header := base64.RawURLEncoding.EncodeToString([]byte(`{"typ":"JWT","alg":"PS256"}`))
	now := time.Now().UTC()
	claims, err := json.Marshal(map[string]any{
		"sub": clientID,
		"iss": clientID,
		"aud": audience,
		"nbf": now.Add(-time.Minute).Unix(),
		"exp": now.Add(time.Minute).Unix(),
	})
	require.NoError(t, err)
	payload := base64.RawURLEncoding.EncodeToString(claims)
	signingInput := header + "." + payload
	sum := sha256.Sum256([]byte(signingInput))
	signature, err := rsa.SignPSS(rand.Reader, key, crypto.SHA256, sum[:], &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash})
	require.NoError(t, err)
	return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature)
}
