diff --git a/appview/db/pipeline.go b/appview/db/pipeline.go index 7295d9f2..e2084d79 100644 --- a/appview/db/pipeline.go +++ b/appview/db/pipeline.go @@ -282,26 +282,38 @@ func GetPipelineStatuses(e Execer, limit int, filters ...orm.Filter) ([]models.P // get all statuses // the where clause here is of the form: // - // where (pipeline_knot = k1 and pipeline_rkey = r1) - // or (pipeline_knot = k2 and pipeline_rkey = r2) + // and ( + // (ps.pipeline_knot = k1 and ps.pipeline_rkey = r1) + // or (ps.pipeline_knot = k2 and ps.pipeline_rkey = r2) + // ) + // + // the join on pipelines and repos enforces that the status was emitted + // by the spindle that is actually registered for the pipeline's repo. conditions = nil args = nil for _, p := range pipelines { - knotFilter := orm.FilterEq("pipeline_knot", p.Knot) - rkeyFilter := orm.FilterEq("pipeline_rkey", p.Rkey) + knotFilter := orm.FilterEq("ps.pipeline_knot", p.Knot) + rkeyFilter := orm.FilterEq("ps.pipeline_rkey", p.Rkey) conditions = append(conditions, fmt.Sprintf("(%s and %s)", knotFilter.Condition(), rkeyFilter.Condition())) args = append(args, p.Knot) args = append(args, p.Rkey) } whereClause = "" if conditions != nil { - whereClause = "where " + strings.Join(conditions, " or ") + whereClause = "and (" + strings.Join(conditions, " or ") + ")" } query = fmt.Sprintf(` select - id, spindle, rkey, pipeline_knot, pipeline_rkey, created, workflow, status, error, exit_code + ps.id, ps.spindle, ps.rkey, ps.pipeline_knot, ps.pipeline_rkey, + ps.created, ps.workflow, ps.status, ps.error, ps.exit_code from - pipeline_statuses + pipeline_statuses ps + join + pipelines p on p.knot = ps.pipeline_knot and p.rkey = ps.pipeline_rkey + join + repos r on r.repo_did = p.repo_did + where + ps.spindle = r.spindle %s `, whereClause) diff --git a/appview/db/pipeline_test.go b/appview/db/pipeline_test.go new file mode 100644 index 00000000..0c49747b --- /dev/null +++ b/appview/db/pipeline_test.go @@ -0,0 +1,116 @@ +package db + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/appview/models" + "tangled.org/core/orm" + spindle "tangled.org/core/spindle/models" + "tangled.org/core/workflow" +) + +// seedPipeline inserts a trigger + pipeline row and returns the pipeline. +func seedPipeline(t *testing.T, d *DB, knot, rkey, repoDid string) models.Pipeline { + t.Helper() + sha := strings.Repeat("a", 40) + ref := "refs/heads/main" + newSha := sha + oldSha := strings.Repeat("0", 40) + trigger := models.Trigger{ + Kind: workflow.TriggerKindPush, + PushRef: &ref, + PushNewSha: &newSha, + PushOldSha: &oldSha, + } + tx, err := d.Begin() + if err != nil { + t.Fatalf("Begin: %v", err) + } + triggerID, err := AddTrigger(tx, trigger) + if err != nil { + tx.Rollback() + t.Fatalf("AddTrigger: %v", err) + } + pipeline := models.Pipeline{ + Knot: knot, + Rkey: rkey, + RepoOwner: syntax.DID("did:plc:owner"), + RepoName: "repo", + RepoDid: repoDid, + TriggerId: int(triggerID), + Sha: sha, + } + if err := AddPipeline(tx, pipeline); err != nil { + tx.Rollback() + t.Fatalf("AddPipeline: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("Commit: %v", err) + } + return pipeline +} + +// seedStatus inserts a pipeline_status row directly. +func seedStatus(t *testing.T, d *DB, spindleInstance, rkey, pipelineKnot, pipelineRkey, workflow string) { + t.Helper() + status := models.PipelineStatus{ + Spindle: spindleInstance, + Rkey: rkey, + PipelineKnot: pipelineKnot, + PipelineRkey: pipelineRkey, + Workflow: workflow, + Status: spindle.StatusKindSuccess, + Created: time.Now(), + } + if err := AddPipelineStatus(context.Background(), d, status); err != nil { + t.Fatalf("AddPipelineStatus: %v", err) + } +} + +// TestGetPipelineStatuses_SpindleValidation verifies that GetPipelineStatuses +// only returns statuses emitted by the spindle registered for the pipeline's +// repo, and silently drops statuses from a rogue spindle. +func TestGetPipelineStatuses_SpindleValidation(t *testing.T) { + d := newTestDB(t) + + const ( + knot = "knot.example.com" + correctSpindle = "spindle.example.com" + rogueSpindle = "evil.example.com" + repoDid = "did:plc:testrepo" + pipelineRkey = "pipeline1" + ) + + // seed repo with the correct spindle + repo := seedRepo(t, d, "did:plc:owner", knot, "repo", "repo", repoDid) + if err := UpdateSpindle(d, repo.RepoDid, &[]string{correctSpindle}[0]); err != nil { + t.Fatalf("UpdateSpindle: %v", err) + } + + // seed the pipeline for this repo + seedPipeline(t, d, knot, pipelineRkey, repoDid) + + // insert one status from the correct spindle, one from a rogue spindle + seedStatus(t, d, correctSpindle, "status-valid", knot, pipelineRkey, "build") + seedStatus(t, d, rogueSpindle, "status-rogue", knot, pipelineRkey, "build") + + pipelines, err := GetPipelineStatuses(d, 10, orm.FilterEq("p.repo_did", repoDid)) + if err != nil { + t.Fatalf("GetPipelineStatuses: %v", err) + } + if len(pipelines) != 1 { + t.Fatalf("expected 1 pipeline, got %d", len(pipelines)) + } + + statuses := pipelines[0].Statuses["build"].Data + if len(statuses) != 1 { + t.Fatalf("expected 1 status (from correct spindle), got %d", len(statuses)) + } + if statuses[0].Spindle != correctSpindle { + t.Errorf("expected spindle %q, got %q", correctSpindle, statuses[0].Spindle) + } +}