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, _, _, _ := 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}