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", "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}