Stitch any CI into Tangled
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, 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", 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// TestBuildkiteSpawnInvalidYAML proves a workflow without the
257// required `tack.buildkite.pipeline` field is skipped — no API
258// call, no DB row, no status. A misconfigured workflow shouldn't
259// be silently swept onto some default pipeline.
260func TestBuildkiteSpawnInvalidYAML(t *testing.T) {
261 called := false
262 bk := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
263 called = true
264 })
265 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "s", bk)
266
267 p.Spawn(context.Background(), "knot.example.com", "rkey-z",
268 &tangled.Pipeline_TriggerMetadata{
269 Push: &tangled.Pipeline_PushTriggerData{NewSha: "abc", Ref: "refs/heads/main"},
270 },
271 []*tangled.Pipeline_Workflow{
272 {Name: "broken.yml", Raw: "steps:\n - run: true\n"},
273 },
274 )
275
276 time.Sleep(50 * time.Millisecond)
277 if called {
278 t.Fatal("CreateBuild called for workflow missing tack.buildkite.pipeline")
279 }
280 rows, _ := st.EventsAfter(context.Background(), 0)
281 if len(rows) != 0 {
282 t.Fatalf("got %d events, want 0", len(rows))
283 }
284}
285
286// TestBuildkiteHandleWebhook checks the translation pipeline:
287// recorded build + matching webhook → success status published.
288func TestBuildkiteHandleWebhook(t *testing.T) {
289 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "secret",
290 func(w http.ResponseWriter, r *http.Request) { t.Fatal("buildkite shouldn't be called") })
291
292 // Pre-seed a known build mapping.
293 if err := st.InsertBuildkiteBuild(context.Background(), BuildkiteBuildRef{
294 BuildUUID: "uuid-1",
295 BuildNumber: 7,
296 PipelineSlug: "mypipe",
297 Knot: "knot.example.com",
298 PipelineRkey: "rkey-1",
299 Workflow: "test.yml",
300 PipelineURI: "at://did:web:knot.example.com/sh.tangled.pipeline/rkey-1",
301 }); err != nil {
302 t.Fatalf("InsertBuildkiteBuild: %v", err)
303 }
304
305 err := p.HandleWebhook(context.Background(), buildkite.WebhookPayload{
306 Event: "build.finished",
307 Build: buildkite.Build{ID: "uuid-1", State: "passed"},
308 })
309 if err != nil {
310 t.Fatalf("HandleWebhook: %v", err)
311 }
312
313 rows, err := st.EventsAfter(context.Background(), 0)
314 if err != nil {
315 t.Fatalf("EventsAfter: %v", err)
316 }
317 if len(rows) != 1 {
318 t.Fatalf("got %d events, want 1", len(rows))
319 }
320 var rec tangled.PipelineStatus
321 if err := json.Unmarshal(rows[0].EventJSON, &rec); err != nil {
322 t.Fatalf("decode: %v", err)
323 }
324 if rec.Status != "success" || rec.Workflow != "test.yml" {
325 t.Fatalf("bad status: %+v", rec)
326 }
327}
328
329// TestBuildkiteHandleWebhookIgnored covers the "we don't care" paths:
330// non-build events and unknown builds must be no-op (no publish, no
331// error) so Buildkite doesn't retry them.
332func TestBuildkiteHandleWebhookIgnored(t *testing.T) {
333 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeToken, "secret",
334 func(w http.ResponseWriter, r *http.Request) {})
335
336 // Non-build event: no lookup, no publish.
337 if err := p.HandleWebhook(context.Background(), buildkite.WebhookPayload{
338 Event: "job.started",
339 Build: buildkite.Build{ID: "uuid-x"},
340 }); err != nil {
341 t.Fatalf("HandleWebhook (job.started): %v", err)
342 }
343
344 // Build event for unknown UUID: no publish.
345 if err := p.HandleWebhook(context.Background(), buildkite.WebhookPayload{
346 Event: "build.finished",
347 Build: buildkite.Build{ID: "unknown-uuid", State: "passed"},
348 }); err != nil {
349 t.Fatalf("HandleWebhook (unknown): %v", err)
350 }
351
352 // Known build but unmapped state: no publish.
353 if err := st.InsertBuildkiteBuild(context.Background(), BuildkiteBuildRef{
354 BuildUUID: "uuid-blocked", PipelineSlug: "mypipe",
355 Knot: "k", PipelineRkey: "r", Workflow: "w",
356 PipelineURI: "at://x",
357 }); err != nil {
358 t.Fatalf("seed: %v", err)
359 }
360 if err := p.HandleWebhook(context.Background(), buildkite.WebhookPayload{
361 Event: "build.finished",
362 Build: buildkite.Build{ID: "uuid-blocked", State: "blocked"},
363 }); err != nil {
364 t.Fatalf("HandleWebhook (blocked): %v", err)
365 }
366
367 rows, _ := st.EventsAfter(context.Background(), 0)
368 if len(rows) != 0 {
369 t.Fatalf("got %d events, want 0", len(rows))
370 }
371}
372
373// TestBuildkiteWebhookHandlerHTTP exercises the full HTTP path
374// including auth: a request signed with the wrong secret must be
375// rejected, and a correctly-signed one must reach the provider.
376func TestBuildkiteWebhookHandlerHTTP(t *testing.T) {
377 // Signature mode is the more interesting code path; we cover
378 // token mode in the verifier-level tests above.
379 const secret = "swordfish"
380 p, st, _, _ := newBuildkiteTestProvider(t, buildkite.WebhookModeSignature, secret,
381 func(w http.ResponseWriter, r *http.Request) { /* unused */ })
382
383 // Pre-seed so the provider's HandleWebhook can resolve the build.
384 if err := st.InsertBuildkiteBuild(context.Background(), BuildkiteBuildRef{
385 BuildUUID: "uuid-2",
386 BuildNumber: 9,
387 PipelineSlug: "mypipe",
388 Knot: "knot.example.com",
389 PipelineRkey: "rkey-2",
390 Workflow: "test.yml",
391 PipelineURI: "at://did:web:knot.example.com/sh.tangled.pipeline/rkey-2",
392 }); err != nil {
393 t.Fatalf("seed: %v", err)
394 }
395
396 body, _ := json.Marshal(map[string]any{
397 "event": "build.finished",
398 "build": map[string]any{
399 "id": "uuid-2",
400 "state": "failed",
401 },
402 })
403
404 logger := slog.Default()
405 handler := buildkiteWebhookHandler(logger, p)
406
407 // Unsigned request → 401.
408 t.Run("rejects unsigned", func(t *testing.T) {
409 req := httptest.NewRequest(http.MethodPost, "/webhooks/buildkite",
410 strings.NewReader(string(body)))
411 req.Header.Set("X-Buildkite-Event", "build.finished")
412 w := httptest.NewRecorder()
413 handler(w, req)
414 if w.Code != http.StatusUnauthorized {
415 t.Fatalf("status = %d; want 401", w.Code)
416 }
417 })
418
419 // Wrong-secret request → 401.
420 t.Run("rejects bad signature", func(t *testing.T) {
421 ts := fmt.Sprintf("%d", time.Now().Unix())
422 mac := hmac.New(sha256.New, []byte("wrong"))
423 mac.Write([]byte(ts))
424 mac.Write([]byte("."))
425 mac.Write(body)
426 sig := hex.EncodeToString(mac.Sum(nil))
427
428 req := httptest.NewRequest(http.MethodPost, "/webhooks/buildkite",
429 strings.NewReader(string(body)))
430 req.Header.Set("X-Buildkite-Event", "build.finished")
431 req.Header.Set("X-Buildkite-Signature", "timestamp="+ts+",signature="+sig)
432 w := httptest.NewRecorder()
433 handler(w, req)
434 if w.Code != http.StatusUnauthorized {
435 t.Fatalf("status = %d; want 401", w.Code)
436 }
437 })
438
439 // Valid request → 200, status published.
440 t.Run("accepts valid", func(t *testing.T) {
441 ts := fmt.Sprintf("%d", time.Now().Unix())
442 mac := hmac.New(sha256.New, []byte(secret))
443 mac.Write([]byte(ts))
444 mac.Write([]byte("."))
445 mac.Write(body)
446 sig := hex.EncodeToString(mac.Sum(nil))
447
448 req := httptest.NewRequest(http.MethodPost, "/webhooks/buildkite",
449 strings.NewReader(string(body)))
450 req.Header.Set("X-Buildkite-Event", "build.finished")
451 req.Header.Set("X-Buildkite-Signature", "timestamp="+ts+",signature="+sig)
452 w := httptest.NewRecorder()
453 handler(w, req)
454 if w.Code != http.StatusOK {
455 b, _ := io.ReadAll(w.Body)
456 t.Fatalf("status = %d body=%s; want 200", w.Code, string(b))
457 }
458 rows, _ := st.EventsAfter(context.Background(), 0)
459 if len(rows) != 1 {
460 t.Fatalf("got %d events, want 1", len(rows))
461 }
462 var rec tangled.PipelineStatus
463 if err := json.Unmarshal(rows[0].EventJSON, &rec); err != nil {
464 t.Fatalf("decode: %v", err)
465 }
466 if rec.Status != "failed" {
467 t.Fatalf("status = %q; want failed", rec.Status)
468 }
469 })
470}
471
472// TestBuildkiteWebhookHandlerNoProvider confirms the 503 branch when
473// tack is running with the fake provider — a misdirected webhook
474// must get a clear "not configured here" instead of a misleading
475// 200 OK that silently throws the event away.
476func TestBuildkiteWebhookHandlerNoProvider(t *testing.T) {
477 handler := buildkiteWebhookHandler(slog.Default(), nil)
478 req := httptest.NewRequest(http.MethodPost, "/webhooks/buildkite",
479 strings.NewReader("{}"))
480 w := httptest.NewRecorder()
481 handler(w, req)
482 if w.Code != http.StatusServiceUnavailable {
483 t.Fatalf("status = %d; want 503", w.Code)
484 }
485}
486
487// TestTriggerCommitAndBranch pins the trigger-shape mapping. Each
488// case pairs an input trigger with the (commit, branch) tuple a
489// real CI provider would feed into its build-creation API.
490func TestTriggerCommitAndBranch(t *testing.T) {
491 cases := []struct {
492 name string
493 in *tangled.Pipeline_TriggerMetadata
494 wantCommit string
495 wantBranch string
496 }{
497 {"nil", nil, "", ""},
498 {"push refs/heads",
499 &tangled.Pipeline_TriggerMetadata{
500 Push: &tangled.Pipeline_PushTriggerData{NewSha: "abc", Ref: "refs/heads/main"},
501 },
502 "abc", "main",
503 },
504 {"push tag ref preserved",
505 &tangled.Pipeline_TriggerMetadata{
506 Push: &tangled.Pipeline_PushTriggerData{NewSha: "abc", Ref: "refs/tags/v1"},
507 },
508 "abc", "refs/tags/v1",
509 },
510 {"pull request",
511 &tangled.Pipeline_TriggerMetadata{
512 PullRequest: &tangled.Pipeline_PullRequestTriggerData{
513 SourceSha: "def", SourceBranch: "feature",
514 },
515 },
516 "def", "feature",
517 },
518 {"manual with default branch",
519 &tangled.Pipeline_TriggerMetadata{
520 Manual: &tangled.Pipeline_ManualTriggerData{},
521 Repo: &tangled.Pipeline_TriggerRepo{DefaultBranch: "main"},
522 },
523 "", "main",
524 },
525 }
526 for _, c := range cases {
527 t.Run(c.name, func(t *testing.T) {
528 gotC, gotB := triggerCommitAndBranch(c.in)
529 if gotC != c.wantCommit || gotB != c.wantBranch {
530 t.Fatalf("got (%q,%q); want (%q,%q)",
531 gotC, gotB, c.wantCommit, c.wantBranch)
532 }
533 })
534 }
535}
536
537// TestStripTerminal exercises both shapes of Buildkite's inline
538// timestamp metadata: the well-formed APC envelope ESC _ … BEL, and
539// the bare "_bk;t=<digits>" residue we sometimes see when an upstream
540// processor strips control bytes without understanding the envelope.
541func TestStripTerminal(t *testing.T) {
542 cases := []struct {
543 name string
544 in string
545 want string
546 }{
547 {"empty", "", ""},
548 {"no metadata", "hello world\n", "hello world\n"},
549 {
550 "framed apc",
551 "\x1b_bk;t=1777680702553\x07~~~ Running agent environment hook\n",
552 "~~~ Running agent environment hook\n",
553 },
554 {
555 "multiple framed apc per line",
556 "\x1b_bk;t=1\x07a\x1b_bk;t=2\x07b\n",
557 "ab\n",
558 },
559 {
560 "bare residue",
561 "_bk;t=1777680702553~~~ Running agent environment hook\n",
562 "~~~ Running agent environment hook\n",
563 },
564 {
565 "bare residue mid-line",
566 "remote: Counting objects: 0% _bk;t=1777680705851\n",
567 "remote: Counting objects: 0% \n",
568 },
569 {
570 "unterminated apc dropped",
571 "keep\x1b_bk;t=1no-bel-here",
572 "keep",
573 },
574 {
575 // Underscores unrelated to bk;t= must survive untouched.
576 "underscore not metadata",
577 "foo_bar baz_qux\n",
578 "foo_bar baz_qux\n",
579 },
580 {
581 // CSI colour codes are stripped down to plain text.
582 "strips csi colour",
583 "\x1b[90m# comment\x1b[0m\n",
584 "# comment\n",
585 },
586 {
587 // Clear-to-EOL embedded mid-line is removed too.
588 "strips csi clear-to-eol",
589 "remote: Counting objects: 1% (2/144) \x1b[K\n",
590 "remote: Counting objects: 1% (2/144) \n",
591 },
592 {
593 // OSC title-set sequences (terminated by BEL) drop.
594 "strips osc bel terminated",
595 "before\x1b]0;window title\x07after\n",
596 "beforeafter\n",
597 },
598 {
599 // OSC terminated by ST (ESC '\') also drops.
600 "strips osc st terminated",
601 "before\x1b]0;title\x1b\\after\n",
602 "beforeafter\n",
603 },
604 {
605 // Two-byte escapes like ESC '=' (DECKPAM) drop.
606 "strips two byte escape",
607 "a\x1b=b\n",
608 "ab\n",
609 },
610 }
611 for _, c := range cases {
612 t.Run(c.name, func(t *testing.T) {
613 got := stripTerminal(c.in)
614 if got != c.want {
615 t.Fatalf("got %q; want %q", got, c.want)
616 }
617 })
618 }
619}