package spindle
import (
"context"
"strings"
"sync"
"testing"
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/api/org_tangled"
"tangled.org/core/spindle/models"
"tangled.org/core/spindle/pipeline"
"tangled.org/core/spindle/secrets"
"tangled.org/core/workflow"
)
const trustWorkflow = "engine: dummy\naudience: " + oidcTestAudience + "\nwhen:\n - event: pull_request\n branch: main\nsteps:\n - name: hi\n command: echo hi\n"
type runGrant struct {
secrets []string
caches int
ref string
trustedSource bool
}
func (g runGrant) trusted() bool {
return len(g.secrets) > 0 || g.caches > 0
}
func (g runGrant) has(key string) bool {
for _, k := range g.secrets {
if k == key {
return true
}
}
return false
}
type grantEngine struct {
mu sync.Mutex
grant runGrant
ran bool
}
func (e *grantEngine) InitWorkflow(twf workflow.WorkflowSpec, _ workflow.PipelineSpec) (*models.Workflow, error) {
return &models.Workflow{
Name: twf.Name,
Steps: []models.Step{recordingStep{}},
Caches: []models.CacheEntry{{Key: "deps", Paths: []string{"node_modules"}}},
}, nil
}
func (e *grantEngine) SetupWorkflow(context.Context, models.WorkflowId, *models.Workflow, models.WorkflowLogger) error {
return nil
}
func (e *grantEngine) WorkflowTimeout() time.Duration { return time.Minute }
func (e *grantEngine) DestroyWorkflow(context.Context, models.WorkflowId) error { return nil }
func (e *grantEngine) RunStep(_ context.Context, _ models.WorkflowId, w *models.Workflow, _ int, unlocked []secrets.UnlockedSecret, _ models.WorkflowLogger) error {
e.mu.Lock()
defer e.mu.Unlock()
e.ran = true
e.grant = runGrant{caches: len(w.Caches), ref: w.Environment["TANGLED_REF"], trustedSource: w.TrustedSource}
for _, s := range unlocked {
e.grant.secrets = append(e.grant.secrets, s.Key)
}
return nil
}
type trustEnv struct {
*prPipelineEnv
eng *grantEngine
}
func newTrustEnv(t *testing.T) *trustEnv {
t.Helper()
e := newPRPipelineEnv(t)
addTestSecret(t, e.n.s, testRepoDid)
e.n.s.oidc = oidcTestIssuer(t)
eng := &grantEngine{}
e.n.s.engs = map[string]models.Engine{"dummy": eng}
return &trustEnv{prPipelineEnv: e, eng: eng}
}
func (e *trustEnv) head(t *testing.T) string {
t.Helper()
return e.repo.commitWorkflows(t, map[string]string{"pr.yaml": trustWorkflow})
}
// a patch whose record names where its head came from, the way submitPatch writes it
func (e *trustEnv) patchFrom(rkey, ticket, head string, editor, source syntax.DID, branch string) {
record := e.putPatch(testRepoDid, rkey, ticket, head, editor)
record["source"] = map[string]any{"repo": source.String(), "branch": branch}
e.knot.putVersion(testRepoDid, org_tangled.ReviewPatchNSID, rkey, editor, record)
e.n.consumed(e.emitPatch(testRepoDid, rkey, opCreate))
}
// runs the pipeline's queued job the way a job worker would
func (e *trustEnv) run(t *testing.T, id models.PipelineId) runGrant {
t.Helper()
ctx := context.Background()
for {
job, err := e.n.s.db.DequeueJob(ctx, nil)
if err != nil {
t.Fatal(err)
}
if job == nil {
t.Fatalf("pipeline %s has no queued job", id)
}
if job.PipelineId != id {
continue
}
e.eng.mu.Lock()
e.eng.ran, e.eng.grant = false, runGrant{}
e.eng.mu.Unlock()
e.n.s.runJob(ctx, job)
e.eng.mu.Lock()
defer e.eng.mu.Unlock()
if !e.eng.ran {
t.Fatalf("pipeline %s never ran a step", id)
}
return e.eng.grant
}
}
func (e *trustEnv) approve(t *testing.T, id models.PipelineId) {
t.Helper()
if err := e.n.s.ApprovePipeline(context.Background(), testRepoDid, id); err != nil {
t.Fatal(err)
}
}
func (e *trustEnv) retry(t *testing.T, id models.PipelineId) models.PipelineId {
t.Helper()
retried, err := e.n.s.RetryPipeline(context.Background(), testOwner, testRepoDid, id, nil)
if err != nil {
t.Fatal(err)
}
return retried
}
func wantUntrusted(t *testing.T, what string, got runGrant) {
t.Helper()
if got.trusted() || got.trustedSource || got.has(models.IDTokenEnvVar) {
t.Errorf("%s ran with secrets %v, %d caches and TrustedSource %t, want none: its code isn't the repo's", what, got.secrets, got.caches, got.trustedSource)
}
}
func wantTrusted(t *testing.T, what string, got runGrant) {
t.Helper()
if !got.has("api_key") || !got.has(models.IDTokenEnvVar) || got.caches == 0 || !got.trustedSource {
t.Errorf("%s ran with secrets %v, %d caches and TrustedSource %t, want the repo secret, an id token, its caches and TrustedSource", what, got.secrets, got.caches, got.trustedSource)
}
}
func TestForkPullRunsUntrusted(t *testing.T) {
e := newTrustEnv(t)
e.ticketAs(otherPull, "open", testDave, opCreate)
e.patchFrom("bafydaveone", otherPull, e.head(t), testDave, faultOtherRepoDid, "feature")
first := expectPRPipelines(t, e.n, 1)[0]
wantBlocked(t, e.n, first, true)
e.approve(t, first.ID)
wantUntrusted(t, "an approved fork pull", e.run(t, first.ID))
wantUntrusted(t, "the retry of an approved fork pull", e.run(t, e.retry(t, first.ID)))
e.patchFrom("bafydavetwo", otherPull, e.head(t), testDave, faultOtherRepoDid, "feature")
later := expectPRPipelines(t, e.n, 3)[2]
wantBlocked(t, e.n, later, true)
e.approve(t, later.ID)
wantUntrusted(t, "a later round of an approved fork pull", e.run(t, later.ID))
// a collaborator's fork needs no approval, and still isn't the repo's code
e.ticketAs(pullRkey, "open", testBob, opCreate)
e.patchFrom(patchOne, pullRkey, e.head(t), testBob, faultOtherRepoDid, "feature")
own := expectPRPipelines(t, e.n, 4)[3]
wantBlocked(t, e.n, own, false)
wantUntrusted(t, "a collaborator's fork pull", e.run(t, own.ID))
}
func TestSameRepoBranchPullRunsTrusted(t *testing.T) {
e := newTrustEnv(t)
e.ticketAs(pullRkey, "open", testBob, opCreate)
e.patchFrom(patchOne, pullRkey, e.head(t), testBob, testRepoDid, "feature")
got := expectPRPipelines(t, e.n, 1)[0]
wantBlocked(t, e.n, got, false)
wantTrusted(t, "a collaborator's branch of the repo", e.run(t, got.ID))
wantTrusted(t, "the retry of a collaborator's branch", e.run(t, e.retry(t, got.ID)))
}
// a patch record says what the client claims. nothing checks the branch against the head
func TestClaimedBranchOfANonCollaboratorRunsUntrusted(t *testing.T) {
e := newTrustEnv(t)
e.ticketAs(otherPull, "open", testDave, opCreate)
e.patchFrom("bafydaveone", otherPull, e.head(t), testDave, testRepoDid, "main")
got := expectPRPipelines(t, e.n, 1)[0]
wantBlocked(t, e.n, got, true)
e.approve(t, got.ID)
run := e.run(t, got.ID)
wantUntrusted(t, "an approved pull claiming main", run)
if run.ref == "refs/heads/main" {
t.Errorf("an approved pull claiming main ran as %s, want it scoped to the pull", run.ref)
}
wantUntrusted(t, "its retry", e.run(t, e.retry(t, got.ID)))
}
func (e *trustEnv) enqueue(t *testing.T, id models.PipelineId, trigger workflow.TriggerMetadata, source *workflow.TriggerRepo, blocked bool) models.PipelineId {
t.Helper()
audience := oidcTestAudience
tpl := workflow.PipelineSpec{
TriggerMetadata: &trigger,
Workflows: []*workflow.WorkflowSpec{{Name: "pr.yaml", Engine: "dummy", Raw: trustWorkflow, Audience: &audience}},
}
record, err := pipeline.FromTangled(id, time.Now(), tpl)
if err != nil {
t.Fatal(err)
}
if err := e.n.s.processPipeline(context.Background(), testRepoDid, tpl, record, source, blocked); err != nil {
t.Fatal(err)
}
return id
}
func (e *trustEnv) triggerRepo() *workflow.TriggerRepo {
repoDid := testRepoDid.String()
return &workflow.TriggerRepo{Did: testOwner.String(), Knot: "knot.test", RepoDid: &repoDid}
}
// a stored record still carries whatever branch the patch claimed
func TestStoredPullRunsStayUntrusted(t *testing.T) {
e := newTrustEnv(t)
dave, action := testDave.String(), "opened"
pull := "at://" + testRepoDid.String() + "/" + org_tangled.TrackTicketNSID + "/" + otherPull
stored := func(id models.PipelineId, branch string, blocked bool) models.PipelineId {
return e.enqueue(t, id, workflow.TriggerMetadata{
Kind: string(workflow.TriggerKindPullRequest),
Actor: &dave,
Repo: e.triggerRepo(),
PullRequest: &workflow.PullRequestTriggerData{
Action: &action, Pull: &pull, SourceBranch: branch, SourceSha: e.head(t), TargetBranch: "main",
},
}, e.triggerRepo(), blocked)
}
claimed := stored("3mstoredclaim", "main", true)
e.approve(t, claimed)
wantUntrusted(t, "a stored pull claiming main, approved", e.run(t, claimed))
wantUntrusted(t, "its retry", e.run(t, e.retry(t, claimed)))
fork := stored("3mstoredfork2", "", false)
wantUntrusted(t, "a stored fork pull", e.run(t, fork))
wantUntrusted(t, "its retry", e.run(t, e.retry(t, fork)))
}
func TestPushAndManualRunsKeepTheirTrust(t *testing.T) {
e := newTrustEnv(t)
owner, sha, ref := testOwner.String(), e.head(t), "refs/heads/main"
push := e.enqueue(t, "3mtrustpush22", workflow.TriggerMetadata{
Kind: string(workflow.TriggerKindPush),
Actor: &owner,
Repo: e.triggerRepo(),
Push: &workflow.PushTriggerData{Ref: ref, NewSha: sha, OldSha: strings.Repeat("0", 40)},
}, e.triggerRepo(), false)
wantTrusted(t, "a push", e.run(t, push))
fork := faultOtherRepoDid.String()
manual := e.enqueue(t, "3mtrustmanual", workflow.TriggerMetadata{
Kind: string(workflow.TriggerKindManual),
Actor: &owner,
Repo: e.triggerRepo(),
Manual: &workflow.ManualTriggerData{Sha: sha, Ref: &ref},
SourceRepo: &fork,
}, &workflow.TriggerRepo{RepoDid: &fork, Did: fork, Knot: "knot.test"}, false)
wantUntrusted(t, "a manual run of a fork", e.run(t, manual))
}