Stitch any CI into Tangled
2

Configure Feed

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

1package buildkite 2 3// Tests for the Buildkite REST client + webhook signature/token 4// verifiers. Provider-level (Tangled translation) tests live with 5// the provider in the main package. 6 7import ( 8 "context" 9 "crypto/hmac" 10 "crypto/sha256" 11 "encoding/hex" 12 "encoding/json" 13 "fmt" 14 "io" 15 "net/http" 16 "net/http/httptest" 17 "strings" 18 "testing" 19 "time" 20) 21 22// TestVerifySignature covers the HMAC mode end to end. The reference 23// digest is computed with the documented "<timestamp>.<body>" 24// preimage so the test pins the wire format, not just the helper. 25func TestVerifySignature(t *testing.T) { 26 const secret = "shhh" 27 body := []byte(`{"event":"build.finished"}`) 28 const ts = "1700000000" 29 30 // Pin the clock to "now == ts" so the freshness check is 31 // satisfied for the in-window cases and we can dial it past the 32 // max-age boundary for the stale case below. 33 tsTime := time.Unix(1700000000, 0) 34 prevNow := timeNow 35 timeNow = func() time.Time { return tsTime } 36 defer func() { timeNow = prevNow }() 37 38 mac := hmac.New(sha256.New, []byte(secret)) 39 mac.Write([]byte(ts)) 40 mac.Write([]byte(".")) 41 mac.Write(body) 42 good := hex.EncodeToString(mac.Sum(nil)) 43 44 // Same body/secret/timestamp, but signed for a moment far in 45 // the past relative to our pinned clock. A correct verifier 46 // must reject it even though the HMAC itself is valid, otherwise 47 // captured deliveries replay forever. 48 const staleTS = "1600000000" 49 staleMac := hmac.New(sha256.New, []byte(secret)) 50 staleMac.Write([]byte(staleTS)) 51 staleMac.Write([]byte(".")) 52 staleMac.Write(body) 53 staleSig := hex.EncodeToString(staleMac.Sum(nil)) 54 55 cases := []struct { 56 name string 57 header string 58 secret string 59 body []byte 60 wantErr bool 61 }{ 62 {"valid", "timestamp=" + ts + ",signature=" + good, secret, body, false}, 63 {"valid with whitespace", " timestamp=" + ts + " , signature=" + good + " ", secret, body, false}, 64 {"empty header", "", secret, body, true}, 65 {"empty server secret", "timestamp=" + ts + ",signature=" + good, "", body, true}, 66 {"missing timestamp", "signature=" + good, secret, body, true}, 67 {"missing signature", "timestamp=" + ts, secret, body, true}, 68 {"non-numeric timestamp", "timestamp=abc,signature=" + good, secret, body, true}, 69 {"wrong signature", "timestamp=" + ts + ",signature=00", secret, body, true}, 70 {"wrong body", "timestamp=" + ts + ",signature=" + good, secret, []byte("nope"), true}, 71 {"stale timestamp", "timestamp=" + staleTS + ",signature=" + staleSig, secret, body, true}, 72 } 73 for _, c := range cases { 74 t.Run(c.name, func(t *testing.T) { 75 err := VerifySignature(c.header, c.secret, c.body) 76 if (err != nil) != c.wantErr { 77 t.Fatalf("err=%v wantErr=%v", err, c.wantErr) 78 } 79 }) 80 } 81} 82 83// TestVerifyToken pins the token-mode behaviour: it must be a 84// constant-time exact-match check, never a prefix or substring. 85func TestVerifyToken(t *testing.T) { 86 cases := []struct { 87 name string 88 header string 89 expect string 90 wantErr bool 91 }{ 92 {"match", "abc123", "abc123", false}, 93 {"empty header", "", "abc123", true}, 94 {"empty expected", "abc123", "", true}, 95 {"mismatch", "abc124", "abc123", true}, 96 {"prefix is not a match", "abc", "abc123", true}, 97 } 98 for _, c := range cases { 99 t.Run(c.name, func(t *testing.T) { 100 err := VerifyToken(c.header, c.expect) 101 if (err != nil) != c.wantErr { 102 t.Fatalf("err=%v wantErr=%v", err, c.wantErr) 103 } 104 }) 105 } 106} 107 108// TestClientCreateBuild covers the request shape we send (auth 109// header, JSON body, URL) and the response decoding for the happy 110// path. 111func TestClientCreateBuild(t *testing.T) { 112 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 113 if r.URL.Path != "/v2/organizations/myorg/pipelines/mypipe/builds" { 114 t.Errorf("bad path %s", r.URL.Path) 115 } 116 if got := r.Header.Get("Authorization"); got != "Bearer tok" { 117 t.Errorf("bad auth %q", got) 118 } 119 if got := r.Header.Get("Content-Type"); got != "application/json" { 120 t.Errorf("bad content-type %q", got) 121 } 122 var got CreateBuildRequest 123 if err := json.NewDecoder(r.Body).Decode(&got); err != nil { 124 t.Fatalf("decode: %v", err) 125 } 126 if got.Commit != "abc" || got.Branch != "main" { 127 t.Errorf("bad body: %+v", got) 128 } 129 if got.MetaData["k"] != "v" { 130 t.Errorf("missing meta_data: %+v", got.MetaData) 131 } 132 w.WriteHeader(http.StatusCreated) 133 _ = json.NewEncoder(w).Encode(Build{ 134 ID: "uuid-1", 135 Number: 42, 136 State: "scheduled", 137 }) 138 })) 139 defer srv.Close() 140 141 prev := APIBase 142 APIBase = srv.URL 143 defer func() { APIBase = prev }() 144 145 c := NewClient("tok") 146 build, err := c.CreateBuild(context.Background(), "myorg", "mypipe", CreateBuildRequest{ 147 Commit: "abc", 148 Branch: "main", 149 MetaData: map[string]string{"k": "v"}, 150 }) 151 if err != nil { 152 t.Fatalf("CreateBuild: %v", err) 153 } 154 if build.ID != "uuid-1" || build.Number != 42 || build.State != "scheduled" { 155 t.Fatalf("unexpected build: %+v", build) 156 } 157} 158 159// TestClientCreateBuildError makes sure non-2xx responses surface 160// the upstream error body — that text ends up in operator logs, so 161// silently dropping it would make misconfigurations very painful to 162// diagnose. 163func TestClientCreateBuildError(t *testing.T) { 164 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 165 w.WriteHeader(http.StatusUnprocessableEntity) 166 fmt.Fprint(w, `{"message":"branch is required"}`) 167 })) 168 defer srv.Close() 169 170 prev := APIBase 171 APIBase = srv.URL 172 defer func() { APIBase = prev }() 173 174 c := NewClient("tok") 175 _, err := c.CreateBuild(context.Background(), "myorg", "mypipe", CreateBuildRequest{}) 176 if err == nil { 177 t.Fatal("expected error") 178 } 179 if !strings.Contains(err.Error(), "branch is required") { 180 t.Fatalf("error missing upstream body: %v", err) 181 } 182} 183 184// TestClientGetJobLog confirms we send the right Accept header (so 185// Buildkite returns plain text, not JSON) and surface 404 as 186// ErrNotFound for callers to translate. 187func TestClientGetJobLog(t *testing.T) { 188 t.Run("ok", func(t *testing.T) { 189 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 190 if got := r.Header.Get("Accept"); got != "text/plain" { 191 t.Errorf("bad accept %q", got) 192 } 193 if !strings.HasSuffix(r.URL.Path, "/builds/7/jobs/job-1/log") { 194 t.Errorf("bad path %s", r.URL.Path) 195 } 196 io.WriteString(w, "line1\nline2\n") 197 })) 198 defer srv.Close() 199 200 prev := APIBase 201 APIBase = srv.URL 202 defer func() { APIBase = prev }() 203 204 body, err := NewClient("tok"). 205 GetJobLog(context.Background(), "myorg", "mypipe", 7, "job-1") 206 if err != nil { 207 t.Fatalf("GetJobLog: %v", err) 208 } 209 if body != "line1\nline2\n" { 210 t.Fatalf("body = %q", body) 211 } 212 }) 213 t.Run("404", func(t *testing.T) { 214 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 215 http.Error(w, "no", http.StatusNotFound) 216 })) 217 defer srv.Close() 218 prev := APIBase 219 APIBase = srv.URL 220 defer func() { APIBase = prev }() 221 222 _, err := NewClient("tok"). 223 GetJobLog(context.Background(), "myorg", "mypipe", 7, "job-1") 224 if err != ErrNotFound { 225 t.Fatalf("err = %v; want ErrNotFound", err) 226 } 227 }) 228}