Stitch any CI into Tangled
2

Configure Feed

Select the types of activity you want to include in your feed.

1package main 2 3// Provider-level integration tests for the Buildkite implementation: 4// Spawn → CreateBuild + persist + initial pending publish, and 5// HandleWebhook → translate state + publish status. Buildkite itself 6// is stubbed with httptest so the tests don't need network access. 7 8import ( 9 "context" 10 "crypto/hmac" 11 "crypto/sha256" 12 "encoding/hex" 13 "encoding/json" 14 "fmt" 15 "io" 16 "log/slog" 17 "net/http" 18 "net/http/httptest" 19 "strings" 20 "testing" 21 "time" 22 23 "tangled.org/core/api/tangled" 24 25 "go.mitchellh.com/tack/internal/buildkite" 26) 27 28// newBuildkiteTestProvider wires a buildkiteProvider against an 29// httptest server impersonating api.buildkite.com. Returns the 30// store/broker so tests can inspect publishes + persistence. 31func newBuildkiteTestProvider( 32 t *testing.T, 33 mode buildkite.WebhookMode, 34 secret string, 35 bkHandler http.HandlerFunc, 36) (*buildkiteProvider, *store, *broker, *httptest.Server) { 37 t.Helper() 38 srv := httptest.NewServer(bkHandler) 39 t.Cleanup(srv.Close) 40 41 prev := buildkite.APIBase 42 buildkite.APIBase = srv.URL 43 t.Cleanup(func() { buildkite.APIBase = prev }) 44 45 st := newTestStore(t) 46 br := newBroker(st) 47 logger := slog.Default() 48 p := newBuildkiteProvider( 49 br, st, 50 buildkite.NewClient("tok"), 51 "myorg", 52 secret, mode, 53 logger, 54 ) 55 return p, st, br, srv 56} 57 58// TestBuildkiteSpawn covers the full create-build path: trigger → 59// API call → DB row → "pending" status on the broker. 60func TestBuildkiteSpawn(t *testing.T) { 61 bk := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 62 w.WriteHeader(http.StatusCreated) 63 _ = json.NewEncoder(w).Encode(buildkite.Build{ 64 ID: "uuid-1", 65 Number: 7, 66 }) 67 }) 68 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "secret", bk) 69 70 trigger := &tangled.Pipeline_TriggerMetadata{ 71 Push: &tangled.Pipeline_PushTriggerData{ 72 NewSha: "abcdef0123", 73 Ref: "refs/heads/main", 74 }, 75 } 76 workflows := []*tangled.Pipeline_Workflow{ 77 {Name: "test.yml", Raw: "tack:\n buildkite:\n pipeline: mypipe\n"}, 78 } 79 80 p.Spawn(context.Background(), "knot.example.com", "rkey-1", trigger, workflows) 81 82 // Spawn fans out into goroutines; wait briefly for the side 83 // effects to land. The store row is the load-bearing artifact 84 // — once it's present, the publish has already happened too. 85 deadline := time.Now().Add(2 * time.Second) 86 var ref *BuildkiteBuildRef 87 for time.Now().Before(deadline) { 88 var err error 89 ref, err = st.LookupBuildkiteBuildByUUID(context.Background(), "uuid-1") 90 if err != nil { 91 t.Fatalf("lookup: %v", err) 92 } 93 if ref != nil { 94 break 95 } 96 time.Sleep(20 * time.Millisecond) 97 } 98 if ref == nil { 99 t.Fatal("buildkite build row not persisted within deadline") 100 } 101 if ref.Workflow != "test.yml" || ref.Knot != "knot.example.com" || ref.PipelineRkey != "rkey-1" { 102 t.Fatalf("ref mismatch: %+v", ref) 103 } 104 if ref.PipelineSlug != "mypipe" || ref.BuildNumber != 7 { 105 t.Fatalf("buildkite ref mismatch: %+v", ref) 106 } 107 108 // One pending status should be on the events log. 109 rows, err := st.EventsAfter(context.Background(), 0) 110 if err != nil { 111 t.Fatalf("EventsAfter: %v", err) 112 } 113 if len(rows) != 1 { 114 t.Fatalf("got %d events, want 1", len(rows)) 115 } 116 var rec tangled.PipelineStatus 117 if err := json.Unmarshal(rows[0].EventJSON, &rec); err != nil { 118 t.Fatalf("decode status: %v", err) 119 } 120 if rec.Status != "pending" || rec.Workflow != "test.yml" { 121 t.Fatalf("unexpected status: %+v", rec) 122 } 123 if !strings.Contains(rec.Pipeline, "knot.example.com") || 124 !strings.Contains(rec.Pipeline, "rkey-1") { 125 t.Fatalf("pipeline ATURI wrong: %s", rec.Pipeline) 126 } 127} 128 129// TestBuildkiteSpawnNoCommit confirms we don't fire a build when the 130// trigger has no commit to build — kicking one off would resolve to 131// whatever main looks like at agent-fetch time, which is dangerously 132// surprising. 133func TestBuildkiteSpawnNoCommit(t *testing.T) { 134 called := false 135 bk := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 136 called = true 137 }) 138 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "secret", bk) 139 140 p.Spawn(context.Background(), "knot.example.com", "rkey-1", 141 &tangled.Pipeline_TriggerMetadata{Manual: &tangled.Pipeline_ManualTriggerData{}}, 142 []*tangled.Pipeline_Workflow{{Name: "test.yml", 143 Raw: "tack:\n buildkite:\n pipeline: mypipe\n"}}, 144 ) 145 146 // Give any rogue goroutine a moment. 147 time.Sleep(50 * time.Millisecond) 148 if called { 149 t.Fatal("CreateBuild called despite missing commit") 150 } 151 rows, _ := st.EventsAfter(context.Background(), 0) 152 if len(rows) != 0 { 153 t.Fatalf("got %d events, want 0", len(rows)) 154 } 155} 156 157// TestBuildkiteSpawnWorkflowConfig pins the YAML → create-build 158// translation: `tack.buildkite.{pipeline,org}` pick the request URL, 159// `clean_checkout` flows through, and the trigger's PR target 160// branch lands as `pull_request_base_branch` automatically. 161func TestBuildkiteSpawnWorkflowConfig(t *testing.T) { 162 type captured struct { 163 path string 164 body buildkite.CreateBuildRequest 165 } 166 gotCh := make(chan captured, 1) 167 bk := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 168 var body buildkite.CreateBuildRequest 169 _ = json.NewDecoder(r.Body).Decode(&body) 170 gotCh <- captured{path: r.URL.Path, body: body} 171 w.WriteHeader(http.StatusCreated) 172 _ = json.NewEncoder(w).Encode(buildkite.Build{ID: "uuid-9", Number: 9}) 173 }) 174 p, _, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "s", bk) 175 176 raw := strings.Join([]string{ 177 "tack:", 178 " buildkite:", 179 " pipeline: workflow-pipe", 180 " org: workflow-org", 181 " clean_checkout: true", 182 }, "\n") + "\n" 183 184 trigger := &tangled.Pipeline_TriggerMetadata{ 185 PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 186 SourceSha: "deadbeef", 187 SourceBranch: "feature", 188 TargetBranch: "main", 189 }, 190 } 191 192 p.Spawn(context.Background(), "knot.example.com", "rkey-x", trigger, 193 []*tangled.Pipeline_Workflow{{Name: "ci.yml", Raw: raw}}) 194 195 select { 196 case got := <-gotCh: 197 // URL must reflect YAML org + pipeline. 198 if !strings.Contains(got.path, "/organizations/workflow-org/pipelines/workflow-pipe/") { 199 t.Fatalf("path = %q", got.path) 200 } 201 if got.body.Commit != "deadbeef" || got.body.Branch != "feature" { 202 t.Fatalf("commit/branch = %q/%q", got.body.Commit, got.body.Branch) 203 } 204 // tack-managed env/meta still present. 205 if got.body.Env["TACK_WORKFLOW"] != "ci.yml" { 206 t.Fatalf("env[TACK_WORKFLOW] missing: %+v", got.body.Env) 207 } 208 if got.body.MetaData[bkMetaWorkflow] != "ci.yml" { 209 t.Fatalf("meta_data missing identity tuple: %+v", got.body.MetaData) 210 } 211 if !got.body.CleanCheckout { 212 t.Fatalf("clean_checkout not set") 213 } 214 // IgnorePipelineBranchFilters is hard-coded true; see 215 // buildCreateRequest comment. 216 if !got.body.IgnorePipelineBranchFilters { 217 t.Fatalf("ignore_pipeline_branch_filters not on") 218 } 219 if got.body.PullRequestBaseBranch != "main" { 220 t.Fatalf("pr base branch = %q; want main", 221 got.body.PullRequestBaseBranch) 222 } 223 case <-time.After(2 * time.Second): 224 t.Fatal("CreateBuild not called") 225 } 226} 227 228// TestBuildkiteSpawnInvalidYAML proves a workflow without the 229// required `tack.buildkite.pipeline` field is skipped — no API 230// call, no DB row, no status. A misconfigured workflow shouldn't 231// be silently swept onto some default pipeline. 232func TestBuildkiteSpawnInvalidYAML(t *testing.T) { 233 called := false 234 bk := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 235 called = true 236 }) 237 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "s", bk) 238 239 p.Spawn(context.Background(), "knot.example.com", "rkey-z", 240 &tangled.Pipeline_TriggerMetadata{ 241 Push: &tangled.Pipeline_PushTriggerData{NewSha: "abc", Ref: "refs/heads/main"}, 242 }, 243 []*tangled.Pipeline_Workflow{ 244 {Name: "broken.yml", Raw: "steps:\n - run: true\n"}, 245 }, 246 ) 247 248 time.Sleep(50 * time.Millisecond) 249 if called { 250 t.Fatal("CreateBuild called for workflow missing tack.buildkite.pipeline") 251 } 252 rows, _ := st.EventsAfter(context.Background(), 0) 253 if len(rows) != 0 { 254 t.Fatalf("got %d events, want 0", len(rows)) 255 } 256} 257 258// TestBuildkiteHandleWebhook checks the translation pipeline: 259// recorded build + matching webhook → success status published. 260func TestBuildkiteHandleWebhook(t *testing.T) { 261 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "secret", 262 func(w http.ResponseWriter, r *http.Request) { t.Fatal("buildkite shouldn't be called") }) 263 264 // Pre-seed a known build mapping. 265 if err := st.InsertBuildkiteBuild(context.Background(), BuildkiteBuildRef{ 266 BuildUUID: "uuid-1", 267 BuildNumber: 7, 268 PipelineSlug: "mypipe", 269 Knot: "knot.example.com", 270 PipelineRkey: "rkey-1", 271 Workflow: "test.yml", 272 PipelineURI: "at://did:web:knot.example.com/sh.tangled.pipeline/rkey-1", 273 }); err != nil { 274 t.Fatalf("InsertBuildkiteBuild: %v", err) 275 } 276 277 err := p.HandleWebhook(context.Background(), buildkite.WebhookPayload{ 278 Event: "build.finished", 279 Build: buildkite.Build{ID: "uuid-1", State: "passed"}, 280 }) 281 if err != nil { 282 t.Fatalf("HandleWebhook: %v", err) 283 } 284 285 rows, err := st.EventsAfter(context.Background(), 0) 286 if err != nil { 287 t.Fatalf("EventsAfter: %v", err) 288 } 289 if len(rows) != 1 { 290 t.Fatalf("got %d events, want 1", len(rows)) 291 } 292 var rec tangled.PipelineStatus 293 if err := json.Unmarshal(rows[0].EventJSON, &rec); err != nil { 294 t.Fatalf("decode: %v", err) 295 } 296 if rec.Status != "success" || rec.Workflow != "test.yml" { 297 t.Fatalf("bad status: %+v", rec) 298 } 299} 300 301// TestBuildkiteHandleWebhookIgnored covers the "we don't care" paths: 302// non-build events and unknown builds must be no-op (no publish, no 303// error) so Buildkite doesn't retry them. 304func TestBuildkiteHandleWebhookIgnored(t *testing.T) { 305 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "secret", 306 func(w http.ResponseWriter, r *http.Request) {}) 307 308 // Non-build event: no lookup, no publish. 309 if err := p.HandleWebhook(context.Background(), buildkite.WebhookPayload{ 310 Event: "job.started", 311 Build: buildkite.Build{ID: "uuid-x"}, 312 }); err != nil { 313 t.Fatalf("HandleWebhook (job.started): %v", err) 314 } 315 316 // Build event for unknown UUID: no publish. 317 if err := p.HandleWebhook(context.Background(), buildkite.WebhookPayload{ 318 Event: "build.finished", 319 Build: buildkite.Build{ID: "unknown-uuid", State: "passed"}, 320 }); err != nil { 321 t.Fatalf("HandleWebhook (unknown): %v", err) 322 } 323 324 // Known build but unmapped state: no publish. 325 if err := st.InsertBuildkiteBuild(context.Background(), BuildkiteBuildRef{ 326 BuildUUID: "uuid-blocked", PipelineSlug: "mypipe", 327 Knot: "k", PipelineRkey: "r", Workflow: "w", 328 PipelineURI: "at://x", 329 }); err != nil { 330 t.Fatalf("seed: %v", err) 331 } 332 if err := p.HandleWebhook(context.Background(), buildkite.WebhookPayload{ 333 Event: "build.finished", 334 Build: buildkite.Build{ID: "uuid-blocked", State: "blocked"}, 335 }); err != nil { 336 t.Fatalf("HandleWebhook (blocked): %v", err) 337 } 338 339 rows, _ := st.EventsAfter(context.Background(), 0) 340 if len(rows) != 0 { 341 t.Fatalf("got %d events, want 0", len(rows)) 342 } 343} 344 345// TestBuildkiteWebhookHandlerHTTP exercises the full HTTP path 346// including auth: a request signed with the wrong secret must be 347// rejected, and a correctly-signed one must reach the provider. 348func TestBuildkiteWebhookHandlerHTTP(t *testing.T) { 349 // Signature mode is the more interesting code path; we cover 350 // token mode in the verifier-level tests above. 351 const secret = "swordfish" 352 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeSignature, secret, 353 func(w http.ResponseWriter, r *http.Request) { /* unused */ }) 354 355 // Pre-seed so the provider's HandleWebhook can resolve the build. 356 if err := st.InsertBuildkiteBuild(context.Background(), BuildkiteBuildRef{ 357 BuildUUID: "uuid-2", 358 BuildNumber: 9, 359 PipelineSlug: "mypipe", 360 Knot: "knot.example.com", 361 PipelineRkey: "rkey-2", 362 Workflow: "test.yml", 363 PipelineURI: "at://did:web:knot.example.com/sh.tangled.pipeline/rkey-2", 364 }); err != nil { 365 t.Fatalf("seed: %v", err) 366 } 367 368 body, _ := json.Marshal(map[string]any{ 369 "event": "build.finished", 370 "build": map[string]any{ 371 "id": "uuid-2", 372 "state": "failed", 373 }, 374 }) 375 376 logger := slog.Default() 377 handler := buildkiteWebhookHandler(logger, p) 378 379 // Unsigned request → 401. 380 t.Run("rejects unsigned", func(t *testing.T) { 381 req := httptest.NewRequest(http.MethodPost, "/webhooks/buildkite", 382 strings.NewReader(string(body))) 383 req.Header.Set("X-Buildkite-Event", "build.finished") 384 w := httptest.NewRecorder() 385 handler(w, req) 386 if w.Code != http.StatusUnauthorized { 387 t.Fatalf("status = %d; want 401", w.Code) 388 } 389 }) 390 391 // Wrong-secret request → 401. 392 t.Run("rejects bad signature", func(t *testing.T) { 393 ts := fmt.Sprintf("%d", time.Now().Unix()) 394 mac := hmac.New(sha256.New, []byte("wrong")) 395 mac.Write([]byte(ts)) 396 mac.Write([]byte(".")) 397 mac.Write(body) 398 sig := hex.EncodeToString(mac.Sum(nil)) 399 400 req := httptest.NewRequest(http.MethodPost, "/webhooks/buildkite", 401 strings.NewReader(string(body))) 402 req.Header.Set("X-Buildkite-Event", "build.finished") 403 req.Header.Set("X-Buildkite-Signature", "timestamp="+ts+",signature="+sig) 404 w := httptest.NewRecorder() 405 handler(w, req) 406 if w.Code != http.StatusUnauthorized { 407 t.Fatalf("status = %d; want 401", w.Code) 408 } 409 }) 410 411 // Valid request → 200, status published. 412 t.Run("accepts valid", func(t *testing.T) { 413 ts := fmt.Sprintf("%d", time.Now().Unix()) 414 mac := hmac.New(sha256.New, []byte(secret)) 415 mac.Write([]byte(ts)) 416 mac.Write([]byte(".")) 417 mac.Write(body) 418 sig := hex.EncodeToString(mac.Sum(nil)) 419 420 req := httptest.NewRequest(http.MethodPost, "/webhooks/buildkite", 421 strings.NewReader(string(body))) 422 req.Header.Set("X-Buildkite-Event", "build.finished") 423 req.Header.Set("X-Buildkite-Signature", "timestamp="+ts+",signature="+sig) 424 w := httptest.NewRecorder() 425 handler(w, req) 426 if w.Code != http.StatusOK { 427 b, _ := io.ReadAll(w.Body) 428 t.Fatalf("status = %d body=%s; want 200", w.Code, string(b)) 429 } 430 rows, _ := st.EventsAfter(context.Background(), 0) 431 if len(rows) != 1 { 432 t.Fatalf("got %d events, want 1", len(rows)) 433 } 434 var rec tangled.PipelineStatus 435 if err := json.Unmarshal(rows[0].EventJSON, &rec); err != nil { 436 t.Fatalf("decode: %v", err) 437 } 438 if rec.Status != "failed" { 439 t.Fatalf("status = %q; want failed", rec.Status) 440 } 441 }) 442} 443 444// TestBuildkiteWebhookHandlerNoProvider confirms the 503 branch when 445// tack is running with the fake provider — a misdirected webhook 446// must get a clear "not configured here" instead of a misleading 447// 200 OK that silently throws the event away. 448func TestBuildkiteWebhookHandlerNoProvider(t *testing.T) { 449 handler := buildkiteWebhookHandler(slog.Default(), nil) 450 req := httptest.NewRequest(http.MethodPost, "/webhooks/buildkite", 451 strings.NewReader("{}")) 452 w := httptest.NewRecorder() 453 handler(w, req) 454 if w.Code != http.StatusServiceUnavailable { 455 t.Fatalf("status = %d; want 503", w.Code) 456 } 457} 458 459// TestTriggerCommitAndBranch pins the trigger-shape mapping. Each 460// case pairs an input trigger with the (commit, branch) tuple a 461// real CI provider would feed into its build-creation API. 462func TestTriggerCommitAndBranch(t *testing.T) { 463 cases := []struct { 464 name string 465 in *tangled.Pipeline_TriggerMetadata 466 wantCommit string 467 wantBranch string 468 }{ 469 {"nil", nil, "", ""}, 470 {"push refs/heads", 471 &tangled.Pipeline_TriggerMetadata{ 472 Push: &tangled.Pipeline_PushTriggerData{NewSha: "abc", Ref: "refs/heads/main"}, 473 }, 474 "abc", "main", 475 }, 476 {"push tag ref preserved", 477 &tangled.Pipeline_TriggerMetadata{ 478 Push: &tangled.Pipeline_PushTriggerData{NewSha: "abc", Ref: "refs/tags/v1"}, 479 }, 480 "abc", "refs/tags/v1", 481 }, 482 {"pull request", 483 &tangled.Pipeline_TriggerMetadata{ 484 PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 485 SourceSha: "def", SourceBranch: "feature", 486 }, 487 }, 488 "def", "feature", 489 }, 490 {"manual with default branch", 491 &tangled.Pipeline_TriggerMetadata{ 492 Manual: &tangled.Pipeline_ManualTriggerData{}, 493 Repo: &tangled.Pipeline_TriggerRepo{DefaultBranch: "main"}, 494 }, 495 "", "main", 496 }, 497 } 498 for _, c := range cases { 499 t.Run(c.name, func(t *testing.T) { 500 gotC, gotB := triggerCommitAndBranch(c.in) 501 if gotC != c.wantCommit || gotB != c.wantBranch { 502 t.Fatalf("got (%q,%q); want (%q,%q)", 503 gotC, gotB, c.wantCommit, c.wantBranch) 504 } 505 }) 506 } 507}