Stitch any CI into Tangled
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}