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", "did:plc:actor", 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", "did:plc:actor", 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, st, _, _ := 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", "did:plc:actor", 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 // The workflow's `org` override has to round-trip through 228 // the store: /logs (and any future post-creation API call) 229 // looks the row up by tuple and re-issues calls against the 230 // org we recorded here. If we drop it, log retrieval falls 231 // back to defaultOrg and 404s for cross-org workflows. 232 deadline := time.Now().Add(2 * time.Second) 233 var ref *BuildkiteBuildRef 234 for time.Now().Before(deadline) { 235 var err error 236 ref, err = st.LookupBuildkiteBuildByTuple( 237 context.Background(), 238 "knot.example.com", "rkey-x", "ci.yml", 239 ) 240 if err != nil { 241 t.Fatalf("lookup: %v", err) 242 } 243 if ref != nil { 244 break 245 } 246 time.Sleep(20 * time.Millisecond) 247 } 248 if ref == nil { 249 t.Fatal("buildkite build row not persisted within deadline") 250 } 251 if ref.Org != "workflow-org" { 252 t.Fatalf("ref.Org = %q; want %q", ref.Org, "workflow-org") 253 } 254} 255 256// TestBuildkiteSpawnPersistsResolvedOrg pins that Spawn writes the 257// *resolved* org to the buildkite_builds row when the workflow YAML 258// omits `tack.buildkite.org`. Storing cfg.Org (empty) here would let 259// historical lookups silently follow a later change to defaultOrg 260// into the wrong organisation; storing the value we actually issued 261// the CreateBuild against keeps each row self-describing. 262func TestBuildkiteSpawnPersistsResolvedOrg(t *testing.T) { 263 bk := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 264 // The default org in newBuildkiteTestProvider is "myorg"; 265 // the URL must reflect the resolved fallback so we know 266 // what value the row is expected to capture. 267 if !strings.Contains(r.URL.Path, "/organizations/myorg/") { 268 t.Errorf("CreateBuild path = %q; want /organizations/myorg/", r.URL.Path) 269 } 270 w.WriteHeader(http.StatusCreated) 271 _ = json.NewEncoder(w).Encode(buildkite.Build{ID: "uuid-r", Number: 3}) 272 }) 273 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "s", bk) 274 275 p.Spawn(context.Background(), "knot.example.com", "rkey-r", "did:plc:actor", 276 &tangled.Pipeline_TriggerMetadata{ 277 Push: &tangled.Pipeline_PushTriggerData{ 278 NewSha: "abc", Ref: "refs/heads/main", 279 }, 280 }, 281 // No `org:` under tack.buildkite — must fall back to the 282 // provider's defaultOrg AND persist that resolved value. 283 []*tangled.Pipeline_Workflow{ 284 {Name: "ci.yml", Raw: "tack:\n buildkite:\n pipeline: mypipe\n"}, 285 }, 286 ) 287 288 deadline := time.Now().Add(2 * time.Second) 289 var ref *BuildkiteBuildRef 290 for time.Now().Before(deadline) { 291 var err error 292 ref, err = st.LookupBuildkiteBuildByUUID(context.Background(), "uuid-r") 293 if err != nil { 294 t.Fatalf("lookup: %v", err) 295 } 296 if ref != nil { 297 break 298 } 299 time.Sleep(20 * time.Millisecond) 300 } 301 if ref == nil { 302 t.Fatal("buildkite build row not persisted within deadline") 303 } 304 // The whole point of the change: ref.Org records the org the 305 // build was created against, not "" (which would hand off to 306 // whatever defaultOrg happens to be at read time). 307 if ref.Org != "myorg" { 308 t.Fatalf("ref.Org = %q; want %q (resolved defaultOrg)", 309 ref.Org, "myorg") 310 } 311 312 // Belt-and-braces: simulate defaultOrg drifting after the row 313 // was written. Logs() should still target the org persisted on 314 // the row, not the new default. We swap defaultOrg out and 315 // stand up a sibling httptest server that fails the test if 316 // anything but /organizations/myorg/ is requested. 317 gotPath := make(chan string, 1) 318 logSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 319 gotPath <- r.URL.Path 320 w.WriteHeader(http.StatusOK) 321 _ = json.NewEncoder(w).Encode(buildkite.Build{ 322 ID: "uuid-r", Number: 3, Jobs: nil, 323 }) 324 })) 325 t.Cleanup(logSrv.Close) 326 prev := buildkite.APIBase 327 buildkite.APIBase = logSrv.URL 328 t.Cleanup(func() { buildkite.APIBase = prev }) 329 330 p.defaultOrg = "newdefault" 331 ch, err := p.Logs(context.Background(), "knot.example.com", "rkey-r", "ci.yml") 332 if err != nil { 333 t.Fatalf("Logs: %v", err) 334 } 335 // Drain so the goroutine completes; we only care about the 336 // initial GetBuild path. 337 for range ch { 338 } 339 select { 340 case path := <-gotPath: 341 if !strings.Contains(path, "/organizations/myorg/") { 342 t.Fatalf("Logs hit %q; want path containing /organizations/myorg/", path) 343 } 344 case <-time.After(2 * time.Second): 345 t.Fatal("Logs did not call GetBuild") 346 } 347} 348 349// TestBuildkiteSpawnInvalidYAML proves a workflow without the 350// required `tack.buildkite.pipeline` field is skipped — no API 351// call, no DB row, no status. A misconfigured workflow shouldn't 352// be silently swept onto some default pipeline. 353func TestBuildkiteSpawnInvalidYAML(t *testing.T) { 354 called := false 355 bk := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 356 called = true 357 }) 358 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "s", bk) 359 360 p.Spawn(context.Background(), "knot.example.com", "rkey-z", "did:plc:actor", 361 &tangled.Pipeline_TriggerMetadata{ 362 Push: &tangled.Pipeline_PushTriggerData{NewSha: "abc", Ref: "refs/heads/main"}, 363 }, 364 []*tangled.Pipeline_Workflow{ 365 {Name: "broken.yml", Raw: "steps:\n - run: true\n"}, 366 }, 367 ) 368 369 time.Sleep(50 * time.Millisecond) 370 if called { 371 t.Fatal("CreateBuild called for workflow missing tack.buildkite.pipeline") 372 } 373 rows, _ := st.EventsAfter(context.Background(), 0) 374 if len(rows) != 0 { 375 t.Fatalf("got %d events, want 0", len(rows)) 376 } 377} 378 379// TestBuildkiteHandleWebhook checks the translation pipeline: 380// recorded build + matching webhook → success status published. 381func TestBuildkiteHandleWebhook(t *testing.T) { 382 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "secret", 383 func(w http.ResponseWriter, r *http.Request) { t.Fatal("buildkite shouldn't be called") }) 384 385 // Pre-seed a known build mapping. 386 if err := st.InsertBuildkiteBuild(context.Background(), BuildkiteBuildRef{ 387 BuildUUID: "uuid-1", 388 BuildNumber: 7, 389 PipelineSlug: "mypipe", 390 Knot: "knot.example.com", 391 PipelineRkey: "rkey-1", 392 Workflow: "test.yml", 393 PipelineURI: "at://did:web:knot.example.com/sh.tangled.pipeline/rkey-1", 394 }); err != nil { 395 t.Fatalf("InsertBuildkiteBuild: %v", err) 396 } 397 398 err := p.HandleWebhook(context.Background(), buildkite.WebhookPayload{ 399 Event: "build.finished", 400 Build: buildkite.Build{ID: "uuid-1", State: "passed"}, 401 }) 402 if err != nil { 403 t.Fatalf("HandleWebhook: %v", err) 404 } 405 406 rows, err := st.EventsAfter(context.Background(), 0) 407 if err != nil { 408 t.Fatalf("EventsAfter: %v", err) 409 } 410 if len(rows) != 1 { 411 t.Fatalf("got %d events, want 1", len(rows)) 412 } 413 var rec tangled.PipelineStatus 414 if err := json.Unmarshal(rows[0].EventJSON, &rec); err != nil { 415 t.Fatalf("decode: %v", err) 416 } 417 if rec.Status != "success" || rec.Workflow != "test.yml" { 418 t.Fatalf("bad status: %+v", rec) 419 } 420} 421 422// TestBuildkiteHandleWebhookIgnored covers the "we don't care" paths: 423// non-build events and unknown builds must be no-op (no publish, no 424// error) so Buildkite doesn't retry them. 425func TestBuildkiteHandleWebhookIgnored(t *testing.T) { 426 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "secret", 427 func(w http.ResponseWriter, r *http.Request) {}) 428 429 // Non-build event: no lookup, no publish. 430 if err := p.HandleWebhook(context.Background(), buildkite.WebhookPayload{ 431 Event: "job.started", 432 Build: buildkite.Build{ID: "uuid-x"}, 433 }); err != nil { 434 t.Fatalf("HandleWebhook (job.started): %v", err) 435 } 436 437 // Build event for unknown UUID: no publish. 438 if err := p.HandleWebhook(context.Background(), buildkite.WebhookPayload{ 439 Event: "build.finished", 440 Build: buildkite.Build{ID: "unknown-uuid", State: "passed"}, 441 }); err != nil { 442 t.Fatalf("HandleWebhook (unknown): %v", err) 443 } 444 445 // Known build but unmapped state: no publish. 446 if err := st.InsertBuildkiteBuild(context.Background(), BuildkiteBuildRef{ 447 BuildUUID: "uuid-blocked", PipelineSlug: "mypipe", 448 Knot: "k", PipelineRkey: "r", Workflow: "w", 449 PipelineURI: "at://x", 450 }); err != nil { 451 t.Fatalf("seed: %v", err) 452 } 453 if err := p.HandleWebhook(context.Background(), buildkite.WebhookPayload{ 454 Event: "build.finished", 455 Build: buildkite.Build{ID: "uuid-blocked", State: "blocked"}, 456 }); err != nil { 457 t.Fatalf("HandleWebhook (blocked): %v", err) 458 } 459 460 rows, _ := st.EventsAfter(context.Background(), 0) 461 if len(rows) != 0 { 462 t.Fatalf("got %d events, want 0", len(rows)) 463 } 464} 465 466// TestBuildkiteHandleWebhookMetaDataFallback covers the race where 467// a webhook arrives before Spawn has persisted the UUID→tuple row. 468// The handler must reconstruct the ref from the build's tack:* 469// meta_data, publish a status event, and opportunistically persist 470// the row so subsequent lookups hit the cache. 471func TestBuildkiteHandleWebhookMetaDataFallback(t *testing.T) { 472 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "secret", 473 func(w http.ResponseWriter, r *http.Request) { t.Fatal("buildkite shouldn't be called") }) 474 475 // No InsertBuildkiteBuild: simulate the race window where 476 // CreateBuild has returned to Buildkite (so a webhook is in 477 // flight) but the provider hasn't yet written the mapping row. 478 err := p.HandleWebhook(context.Background(), buildkite.WebhookPayload{ 479 Event: "build.scheduled", 480 Build: buildkite.Build{ 481 ID: "uuid-race", 482 Number: 42, 483 State: "scheduled", 484 MetaData: map[string]string{ 485 bkMetaKnot: "knot.example.com", 486 bkMetaPipelineRkey: "rkey-race", 487 bkMetaWorkflow: "ci.yml", 488 }, 489 Pipeline: map[string]any{"slug": "mypipe"}, 490 }, 491 Organization: buildkite.Organization{Slug: "myorg"}, 492 }) 493 if err != nil { 494 t.Fatalf("HandleWebhook: %v", err) 495 } 496 497 // Status was published despite the missing cache row. 498 rows, err := st.EventsAfter(context.Background(), 0) 499 if err != nil { 500 t.Fatalf("EventsAfter: %v", err) 501 } 502 if len(rows) != 1 { 503 t.Fatalf("got %d events, want 1", len(rows)) 504 } 505 var rec tangled.PipelineStatus 506 if err := json.Unmarshal(rows[0].EventJSON, &rec); err != nil { 507 t.Fatalf("decode: %v", err) 508 } 509 if rec.Status != "pending" || rec.Workflow != "ci.yml" { 510 t.Fatalf("bad status: %+v", rec) 511 } 512 513 // Opportunistic insert means the row is now cached for any 514 // subsequent webhook (or Logs call), including the Org and 515 // PipelineSlug we recovered from the payload. 516 ref, err := st.LookupBuildkiteBuildByUUID(context.Background(), "uuid-race") 517 if err != nil { 518 t.Fatalf("lookup: %v", err) 519 } 520 if ref == nil { 521 t.Fatal("expected opportunistic InsertBuildkiteBuild, got nil row") 522 } 523 if ref.Workflow != "ci.yml" || ref.Knot != "knot.example.com" || 524 ref.PipelineRkey != "rkey-race" || ref.BuildNumber != 42 || 525 ref.PipelineSlug != "mypipe" || ref.Org != "myorg" { 526 t.Fatalf("ref mismatch: %+v", ref) 527 } 528} 529 530// TestBuildkiteHandleWebhookForeignBuild covers the genuinely-foreign 531// case: a webhook for a build that doesn't carry our tack:* meta_data 532// must remain a silent no-op even though the new fallback path now 533// inspects meta_data. 534func TestBuildkiteHandleWebhookForeignBuild(t *testing.T) { 535 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "secret", 536 func(w http.ResponseWriter, r *http.Request) {}) 537 538 if err := p.HandleWebhook(context.Background(), buildkite.WebhookPayload{ 539 Event: "build.finished", 540 Build: buildkite.Build{ 541 ID: "uuid-foreign", 542 State: "passed", 543 // No tack:* keys: build was triggered outside tack. 544 MetaData: map[string]string{"someone-elses-key": "value"}, 545 }, 546 }); err != nil { 547 t.Fatalf("HandleWebhook: %v", err) 548 } 549 550 rows, _ := st.EventsAfter(context.Background(), 0) 551 if len(rows) != 0 { 552 t.Fatalf("got %d events, want 0", len(rows)) 553 } 554 ref, _ := st.LookupBuildkiteBuildByUUID(context.Background(), "uuid-foreign") 555 if ref != nil { 556 t.Fatalf("foreign build was opportunistically persisted: %+v", ref) 557 } 558} 559 560// TestBuildkiteWebhookHandlerHTTP exercises the full HTTP path 561// including auth: a request signed with the wrong secret must be 562// rejected, and a correctly-signed one must reach the provider. 563func TestBuildkiteWebhookHandlerHTTP(t *testing.T) { 564 // Signature mode is the more interesting code path; we cover 565 // token mode in the verifier-level tests above. 566 const secret = "swordfish" 567 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeSignature, secret, 568 func(w http.ResponseWriter, r *http.Request) { /* unused */ }) 569 570 // Pre-seed so the provider's HandleWebhook can resolve the build. 571 if err := st.InsertBuildkiteBuild(context.Background(), BuildkiteBuildRef{ 572 BuildUUID: "uuid-2", 573 BuildNumber: 9, 574 PipelineSlug: "mypipe", 575 Knot: "knot.example.com", 576 PipelineRkey: "rkey-2", 577 Workflow: "test.yml", 578 PipelineURI: "at://did:web:knot.example.com/sh.tangled.pipeline/rkey-2", 579 }); err != nil { 580 t.Fatalf("seed: %v", err) 581 } 582 583 body, _ := json.Marshal(map[string]any{ 584 "event": "build.finished", 585 "build": map[string]any{ 586 "id": "uuid-2", 587 "state": "failed", 588 }, 589 }) 590 591 logger := slog.Default() 592 handler := buildkiteWebhookHandler(logger, p) 593 594 // Unsigned request → 401. 595 t.Run("rejects unsigned", func(t *testing.T) { 596 req := httptest.NewRequest(http.MethodPost, "/webhooks/buildkite", 597 strings.NewReader(string(body))) 598 req.Header.Set("X-Buildkite-Event", "build.finished") 599 w := httptest.NewRecorder() 600 handler(w, req) 601 if w.Code != http.StatusUnauthorized { 602 t.Fatalf("status = %d; want 401", w.Code) 603 } 604 }) 605 606 // Wrong-secret request → 401. 607 t.Run("rejects bad signature", func(t *testing.T) { 608 ts := fmt.Sprintf("%d", time.Now().Unix()) 609 mac := hmac.New(sha256.New, []byte("wrong")) 610 mac.Write([]byte(ts)) 611 mac.Write([]byte(".")) 612 mac.Write(body) 613 sig := hex.EncodeToString(mac.Sum(nil)) 614 615 req := httptest.NewRequest(http.MethodPost, "/webhooks/buildkite", 616 strings.NewReader(string(body))) 617 req.Header.Set("X-Buildkite-Event", "build.finished") 618 req.Header.Set("X-Buildkite-Signature", "timestamp="+ts+",signature="+sig) 619 w := httptest.NewRecorder() 620 handler(w, req) 621 if w.Code != http.StatusUnauthorized { 622 t.Fatalf("status = %d; want 401", w.Code) 623 } 624 }) 625 626 // Valid request → 200, status published. 627 t.Run("accepts valid", func(t *testing.T) { 628 ts := fmt.Sprintf("%d", time.Now().Unix()) 629 mac := hmac.New(sha256.New, []byte(secret)) 630 mac.Write([]byte(ts)) 631 mac.Write([]byte(".")) 632 mac.Write(body) 633 sig := hex.EncodeToString(mac.Sum(nil)) 634 635 req := httptest.NewRequest(http.MethodPost, "/webhooks/buildkite", 636 strings.NewReader(string(body))) 637 req.Header.Set("X-Buildkite-Event", "build.finished") 638 req.Header.Set("X-Buildkite-Signature", "timestamp="+ts+",signature="+sig) 639 w := httptest.NewRecorder() 640 handler(w, req) 641 if w.Code != http.StatusOK { 642 b, _ := io.ReadAll(w.Body) 643 t.Fatalf("status = %d body=%s; want 200", w.Code, string(b)) 644 } 645 rows, _ := st.EventsAfter(context.Background(), 0) 646 if len(rows) != 1 { 647 t.Fatalf("got %d events, want 1", len(rows)) 648 } 649 var rec tangled.PipelineStatus 650 if err := json.Unmarshal(rows[0].EventJSON, &rec); err != nil { 651 t.Fatalf("decode: %v", err) 652 } 653 if rec.Status != "failed" { 654 t.Fatalf("status = %q; want failed", rec.Status) 655 } 656 }) 657} 658 659// TestBuildkiteWebhookHandlerNoProvider confirms the 503 branch when 660// tack is running with the fake provider — a misdirected webhook 661// must get a clear "not configured here" instead of a misleading 662// 200 OK that silently throws the event away. 663func TestBuildkiteWebhookHandlerNoProvider(t *testing.T) { 664 handler := buildkiteWebhookHandler(slog.Default(), nil) 665 req := httptest.NewRequest(http.MethodPost, "/webhooks/buildkite", 666 strings.NewReader("{}")) 667 w := httptest.NewRecorder() 668 handler(w, req) 669 if w.Code != http.StatusServiceUnavailable { 670 t.Fatalf("status = %d; want 503", w.Code) 671 } 672} 673 674// TestTriggerCommitAndBranch pins the trigger-shape mapping. Each 675// case pairs an input trigger with the (commit, branch) tuple a 676// real CI provider would feed into its build-creation API. 677func TestTriggerCommitAndBranch(t *testing.T) { 678 cases := []struct { 679 name string 680 in *tangled.Pipeline_TriggerMetadata 681 wantCommit string 682 wantBranch string 683 }{ 684 {"nil", nil, "", ""}, 685 {"push refs/heads", 686 &tangled.Pipeline_TriggerMetadata{ 687 Push: &tangled.Pipeline_PushTriggerData{NewSha: "abc", Ref: "refs/heads/main"}, 688 }, 689 "abc", "main", 690 }, 691 {"push tag ref preserved", 692 &tangled.Pipeline_TriggerMetadata{ 693 Push: &tangled.Pipeline_PushTriggerData{NewSha: "abc", Ref: "refs/tags/v1"}, 694 }, 695 "abc", "refs/tags/v1", 696 }, 697 {"pull request", 698 &tangled.Pipeline_TriggerMetadata{ 699 PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 700 SourceSha: "def", SourceBranch: "feature", 701 }, 702 }, 703 "def", "feature", 704 }, 705 {"manual with default branch", 706 &tangled.Pipeline_TriggerMetadata{ 707 Manual: &tangled.Pipeline_ManualTriggerData{}, 708 Repo: &tangled.Pipeline_TriggerRepo{DefaultBranch: "main"}, 709 }, 710 "", "main", 711 }, 712 } 713 for _, c := range cases { 714 t.Run(c.name, func(t *testing.T) { 715 gotC, gotB := triggerCommitAndBranch(c.in) 716 if gotC != c.wantCommit || gotB != c.wantBranch { 717 t.Fatalf("got (%q,%q); want (%q,%q)", 718 gotC, gotB, c.wantCommit, c.wantBranch) 719 } 720 }) 721 } 722} 723 724// TestStripTerminal exercises both shapes of Buildkite's inline 725// timestamp metadata: the well-formed APC envelope ESC _ … BEL, and 726// the bare "_bk;t=<digits>" residue we sometimes see when an upstream 727// processor strips control bytes without understanding the envelope. 728func TestStripTerminal(t *testing.T) { 729 cases := []struct { 730 name string 731 in string 732 want string 733 }{ 734 {"empty", "", ""}, 735 {"no metadata", "hello world\n", "hello world\n"}, 736 { 737 "framed apc", 738 "\x1b_bk;t=1777680702553\x07~~~ Running agent environment hook\n", 739 "~~~ Running agent environment hook\n", 740 }, 741 { 742 "multiple framed apc per line", 743 "\x1b_bk;t=1\x07a\x1b_bk;t=2\x07b\n", 744 "ab\n", 745 }, 746 { 747 "bare residue", 748 "_bk;t=1777680702553~~~ Running agent environment hook\n", 749 "~~~ Running agent environment hook\n", 750 }, 751 { 752 "bare residue mid-line", 753 "remote: Counting objects: 0% _bk;t=1777680705851\n", 754 "remote: Counting objects: 0% \n", 755 }, 756 { 757 "unterminated apc dropped", 758 "keep\x1b_bk;t=1no-bel-here", 759 "keep", 760 }, 761 { 762 // Underscores unrelated to bk;t= must survive untouched. 763 "underscore not metadata", 764 "foo_bar baz_qux\n", 765 "foo_bar baz_qux\n", 766 }, 767 { 768 // CSI colour codes are stripped down to plain text. 769 "strips csi colour", 770 "\x1b[90m# comment\x1b[0m\n", 771 "# comment\n", 772 }, 773 { 774 // Clear-to-EOL embedded mid-line is removed too. 775 "strips csi clear-to-eol", 776 "remote: Counting objects: 1% (2/144) \x1b[K\n", 777 "remote: Counting objects: 1% (2/144) \n", 778 }, 779 { 780 // OSC title-set sequences (terminated by BEL) drop. 781 "strips osc bel terminated", 782 "before\x1b]0;window title\x07after\n", 783 "beforeafter\n", 784 }, 785 { 786 // OSC terminated by ST (ESC '\') also drops. 787 "strips osc st terminated", 788 "before\x1b]0;title\x1b\\after\n", 789 "beforeafter\n", 790 }, 791 { 792 // Two-byte escapes like ESC '=' (DECKPAM) drop. 793 "strips two byte escape", 794 "a\x1b=b\n", 795 "ab\n", 796 }, 797 } 798 for _, c := range cases { 799 t.Run(c.name, func(t *testing.T) { 800 got := stripTerminal(c.in) 801 if got != c.want { 802 t.Fatalf("got %q; want %q", got, c.want) 803 } 804 }) 805 } 806}